diff --git a/packages/db/prisma/migrations/20250218203405_add_is_onboarded_flag/migration.sql b/packages/db/prisma/migrations/20250218203405_add_is_onboarded_flag/migration.sql new file mode 100644 index 00000000..d987dd34 --- /dev/null +++ b/packages/db/prisma/migrations/20250218203405_add_is_onboarded_flag/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "isOnboarded" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b8198890..678c7461 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -120,10 +120,11 @@ model Org { connections Connection[] repos Repo[] secrets Secret[] + isOnboarded Boolean @default(false) - stripeCustomerId String? - stripeSubscriptionStatus StripeSubscriptionStatus? - stripeLastUpdatedAt DateTime? + stripeCustomerId String? + stripeSubscriptionStatus StripeSubscriptionStatus? + stripeLastUpdatedAt DateTime? /// List of pending invites to this organization invites Invite[] @@ -165,14 +166,14 @@ model Secret { // @see : https://authjs.dev/concepts/database-models#user model User { - id String @id @default(cuid()) - name String? - email String? @unique - hashedPassword String? - emailVerified DateTime? - image String? - accounts Account[] - orgs UserToOrg[] + id String @id @default(cuid()) + name String? + email String? @unique + hashedPassword String? + emailVerified DateTime? + image String? + accounts Account[] + orgs UserToOrg[] /// List of pending invites that the user has created invites Invite[] diff --git a/packages/web/package.json b/packages/web/package.json index 54c9805d..93bd9aa0 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "next lint", "test": "vitest", - "dev:emails": "email dev --dir ./src/emails" + "dev:emails": "email dev --dir ./src/emails", + "stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe" }, "dependencies": { "@auth/prisma-adapter": "^2.7.4", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 639fe0e2..8d0bfd07 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -14,15 +14,15 @@ import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { encrypt } from "@sourcebot/crypto" import { getConnection, getLinkedRepos } from "./data/connection"; -import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org, RepoIndexingStatus } from "@sourcebot/db"; +import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { headers } from "next/headers" import { getStripe } from "@/lib/stripe" import { getUser } from "@/data/user"; import { Session } from "next-auth"; import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment"; -import { StripeSubscriptionStatus } from "@sourcebot/db"; import Stripe from "stripe"; -import { SyncStatusMetadataSchema, type NotFoundData } from "@/lib/syncStatusMetadataSchema"; +import { OnboardingSteps } from "./lib/constants"; + const ajv = new Ajv({ validateFormats: false, }); @@ -76,7 +76,7 @@ export const withOrgMembership = async (session: Session, domain: string, fn: message: "You do not have sufficient permissions to perform this action.", } satisfies ServiceError; } - + return fn({ orgId: org.id, userRole: membership.role, @@ -88,15 +88,12 @@ export const isAuthed = async () => { return session != null; } -export const createOrg = (name: string, domain: string, stripeCustomerId?: string): Promise<{ id: number } | ServiceError> => +export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> => withAuth(async (session) => { const org = await prisma.org.create({ data: { name, domain, - stripeCustomerId, - stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE, - stripeLastUpdatedAt: new Date(), members: { create: { role: "OWNER", @@ -115,6 +112,53 @@ export const createOrg = (name: string, domain: string, stripeCustomerId?: strin } }); +export const completeOnboarding = async (stripeCheckoutSessionId: string, domain: string): Promise<{ success: boolean } | ServiceError> => + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const org = await prisma.org.findUnique({ + where: { id: orgId }, + }); + + if (!org) { + return notFound(); + } + + const stripe = getStripe(); + const stripeSession = await stripe.checkout.sessions.retrieve(stripeCheckoutSessionId); + const stripeCustomerId = stripeSession.customer as string; + + // Catch the case where the customer ID doesn't match the org's customer ID + if (org.stripeCustomerId !== stripeCustomerId) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, + message: "Invalid Stripe customer ID", + } satisfies ServiceError; + } + + if (stripeSession.payment_status !== 'paid') { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, + message: "Payment failed", + } satisfies ServiceError; + } + + await prisma.org.update({ + where: { id: orgId }, + data: { + isOnboarded: true, + stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE, + stripeLastUpdatedAt: new Date(), + } + }); + + return { + success: true, + } + }) + ); + export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => withAuth((session) => withOrgMembership(session, domain, async ({ orgId }) => { @@ -436,7 +480,7 @@ export const getCurrentUserRole = async (domain: string): Promise withOrgMembership(session, domain, async ({ userRole }) => { return userRole; - }) + }) ); export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => @@ -491,7 +535,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ }); } }); - + return { success: true, @@ -539,12 +583,12 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su if (!org) { return notFound(); } - - // Incrememnt the seat count - if (org.stripeCustomerId) { - const subscription = await fetchSubscription(org.domain); + + // @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check. + const subscription = await _fetchSubscriptionForOrg(org.id, tx); + if (subscription) { if (isServiceError(subscription)) { - throw orgInvalidSubscription(); + return subscription; } const existingSeatCount = subscription.items.data[0].quantity; @@ -740,57 +784,100 @@ const parseConnectionConfig = (connectionType: string, config: string) => { return parsedConfig; } -export const setupInitialStripeCustomer = async (name: string, domain: string) => - withAuth(async (session) => { - const user = await getUser(session.user.id); - if (!user) { - return ""; - } +export const createOnboardingStripeCheckoutSession = async (domain: string) => + withAuth(async (session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org) { + return notFound(); + } - const stripe = getStripe(); - const origin = (await headers()).get('origin') + const user = await getUser(session.user.id); + if (!user) { + return notFound(); + } - // @nocheckin - const test_clock = await stripe.testHelpers.testClocks.create({ - frozen_time: Math.floor(Date.now() / 1000) - }) + const stripe = getStripe(); + const origin = (await headers()).get('origin'); - const customer = await stripe.customers.create({ - name: user.name!, - email: user.email!, - test_clock: test_clock.id - }) + // @nocheckin + const test_clock = await stripe.testHelpers.testClocks.create({ + frozen_time: Math.floor(Date.now() / 1000) + }); - const prices = await stripe.prices.list({ - product: STRIPE_PRODUCT_ID, - expand: ['data.product'], - }); - const stripeSession = await stripe.checkout.sessions.create({ - ui_mode: 'embedded', - customer: customer.id, - line_items: [ - { - price: prices.data[0].id, - quantity: 1 + // Use the existing customer if it exists, otherwise create a new one. + const customerId = await (async () => { + if (org.stripeCustomerId) { + return org.stripeCustomerId; } - ], - mode: 'subscription', - subscription_data: { - trial_period_days: 7, - trial_settings: { - end_behavior: { - missing_payment_method: 'cancel', + + const customer = await stripe.customers.create({ + name: org.name, + email: user.email ?? undefined, + test_clock: test_clock.id, + description: `Created by ${user.email} on ${domain} (id: ${org.id})`, + }); + + await prisma.org.update({ + where: { + id: org.id, + }, + data: { + stripeCustomerId: customer.id, + } + }); + + return customer.id; + })(); + + + const prices = await stripe.prices.list({ + product: STRIPE_PRODUCT_ID, + expand: ['data.product'], + }); + + const stripeSession = await stripe.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: prices.data[0].id, + quantity: 1 + } + ], + mode: 'subscription', + subscription_data: { + trial_period_days: 7, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, }, }, - }, - payment_method_collection: 'if_required', - return_url: `${origin}/onboard/complete?session_id={CHECKOUT_SESSION_ID}&org_name=${name}&org_domain=${domain}`, - }) + payment_method_collection: 'if_required', + success_url: `${origin}/${domain}/onboard?step=${OnboardingSteps.Complete}&stripe_session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/${domain}/onboard?step=${OnboardingSteps.Checkout}`, + }); - return stripeSession.client_secret!; - }); + if (!stripeSession.url) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, + message: "Failed to create checkout session", + } satisfies ServiceError; + } + + return { + url: stripeSession.url, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + ); -export const getSubscriptionCheckoutRedirect = async (domain: string) => +export const createStripeCheckoutSession = async (domain: string) => withAuth((session) => withOrgMembership(session, domain, async ({ orgId }) => { const org = await prisma.org.findUnique({ @@ -820,35 +907,36 @@ export const getSubscriptionCheckoutRedirect = async (domain: string) => expand: ['data.product'], }); - const createNewSubscription = async () => { - const stripeSession = await stripe.checkout.sessions.create({ - customer: org.stripeCustomerId as string, - payment_method_types: ['card'], - line_items: [ - { - price: prices.data[0].id, - quantity: numOrgMembers - } - ], - mode: 'subscription', - payment_method_collection: 'always', - success_url: `${origin}/${domain}/settings/billing`, - cancel_url: `${origin}/${domain}`, - }); + const stripeSession = await stripe.checkout.sessions.create({ + customer: org.stripeCustomerId as string, + payment_method_types: ['card'], + line_items: [ + { + price: prices.data[0].id, + quantity: numOrgMembers + } + ], + mode: 'subscription', + payment_method_collection: 'always', + success_url: `${origin}/${domain}/settings/billing`, + cancel_url: `${origin}/${domain}`, + }); - return stripeSession.url; + if (!stripeSession.url) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, + message: "Failed to create checkout session", + } satisfies ServiceError; } - const newSubscriptionUrl = await createNewSubscription(); - return newSubscriptionUrl; + return { + url: stripeSession.url, + } }) ) -export async function fetchStripeSession(sessionId: string) { - const stripe = getStripe(); - const stripeSession = await stripe.checkout.sessions.retrieve(sessionId); - return stripeSession; -} + export const getCustomerPortalSessionLink = async (domain: string): Promise => withAuth((session) => @@ -874,29 +962,39 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise => - withAuth(async () => { - const org = await prisma.org.findUnique({ - where: { - domain, - }, - }); +export const fetchSubscription = (domain: string): Promise => + withAuth(async (session) => + withOrgMembership(session, domain, async ({ orgId }) => { + return _fetchSubscriptionForOrg(orgId, prisma); + }) + ); - if (!org || !org.stripeCustomerId) { - return notFound(); - } +const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); - const stripe = getStripe(); - const subscriptions = await stripe.subscriptions.list({ - customer: org.stripeCustomerId - }); + if (!org) { + return notFound(); + } - if (subscriptions.data.length === 0) { - return notFound(); - } - return subscriptions.data[0]; + if (!org.stripeCustomerId) { + return null; + } + + const stripe = getStripe(); + const subscriptions = await stripe.subscriptions.list({ + customer: org.stripeCustomerId }); + if (subscriptions.data.length === 0) { + return orgInvalidSubscription(); + } + return subscriptions.data[0]; +} + export const getSubscriptionBillingEmail = async (domain: string): Promise => withAuth(async (session) => withOrgMembership(session, domain, async ({ orgId }) => { @@ -990,10 +1088,10 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro return notFound(); } - if (org.stripeCustomerId) { - const subscription = await fetchSubscription(domain); + const subscription = await fetchSubscription(domain); + if (subscription) { if (isServiceError(subscription)) { - return orgInvalidSubscription(); + return subscription; } const existingSeatCount = subscription.items.data[0].quantity; @@ -1045,10 +1143,10 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S return notFound(); } - if (org.stripeCustomerId) { - const subscription = await fetchSubscription(domain); + const subscription = await fetchSubscription(domain); + if (subscription) { if (isServiceError(subscription)) { - return orgInvalidSubscription(); + return subscription; } const existingSeatCount = subscription.items.data[0].quantity; @@ -1084,7 +1182,11 @@ export const getSubscriptionData = async (domain: string) => withOrgMembership(session, domain, async () => { const subscription = await fetchSubscription(domain); if (isServiceError(subscription)) { - return orgInvalidSubscription(); + return subscription; + } + + if (!subscription) { + return null; } return { diff --git a/packages/web/src/app/[domain]/connections/components/configEditor.tsx b/packages/web/src/app/[domain]/components/configEditor.tsx similarity index 100% rename from packages/web/src/app/[domain]/connections/components/configEditor.tsx rename to packages/web/src/app/[domain]/components/configEditor.tsx diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx new file mode 100644 index 00000000..30516397 --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type"; +import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; +import { gerritQuickActions } from "../../connections/quickActions"; +import SharedConnectionCreationForm from "./sharedConnectionCreationForm"; + +interface GerritConnectionCreationFormProps { + onCreated?: (id: number) => void; +} + +export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCreationFormProps) => { + const defaultConfig: GerritConnectionConfig = { + type: 'gerrit', + url: "https://gerrit.example.com" + } + + return ( + + type="gerrit" + title="Create a Gerrit connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + name: 'my-gerrit-connection', + }} + schema={gerritSchema} + quickActions={gerritQuickActions} + onCreated={onCreated} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx new file mode 100644 index 00000000..e77f505b --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; +import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; +import { giteaQuickActions } from "../../connections/quickActions"; +import SharedConnectionCreationForm from "./sharedConnectionCreationForm"; + +interface GiteaConnectionCreationFormProps { + onCreated?: (id: number) => void; +} + +export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreationFormProps) => { + const defaultConfig: GiteaConnectionConfig = { + type: 'gitea', + } + + return ( + + type="gitea" + title="Create a Gitea connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + name: 'my-gitea-connection', + }} + schema={giteaSchema} + quickActions={giteaQuickActions} + onCreated={onCreated} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx new file mode 100644 index 00000000..d600c33f --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; +import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; +import { githubQuickActions } from "../../connections/quickActions"; +import SharedConnectionCreationForm from "./sharedConnectionCreationForm"; + +interface GitHubConnectionCreationFormProps { + onCreated?: (id: number) => void; +} + +export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCreationFormProps) => { + const defaultConfig: GithubConnectionConfig = { + type: 'github', + } + + return ( + + type="github" + title="Create a GitHub connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + name: 'my-github-connection', + }} + schema={githubSchema} + quickActions={githubQuickActions} + onCreated={onCreated} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx new file mode 100644 index 00000000..d8c76c71 --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; +import { gitlabQuickActions } from "../../connections/quickActions"; +import SharedConnectionCreationForm from "./sharedConnectionCreationForm"; + +interface GitLabConnectionCreationFormProps { + onCreated?: (id: number) => void; +} + +export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCreationFormProps) => { + const defaultConfig: GitlabConnectionConfig = { + type: 'gitlab', + } + + return ( + + type="gitlab" + title="Create a GitLab connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + name: 'my-gitlab-connection', + }} + schema={gitlabSchema} + quickActions={gitlabQuickActions} + onCreated={onCreated} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts b/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts new file mode 100644 index 00000000..0e22cf0c --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts @@ -0,0 +1,4 @@ +export { GitHubConnectionCreationForm } from "./githubConnectionCreationForm"; +export { GitLabConnectionCreationForm } from "./gitlabConnectionCreationForm"; +export { GiteaConnectionCreationForm } from "./giteaConnectionCreationForm"; +export { GerritConnectionCreationForm } from "./gerritConnectionCreationForm"; diff --git a/packages/web/src/app/[domain]/connections/new/[type]/components/connectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx similarity index 75% rename from packages/web/src/app/[domain]/connections/new/[type]/components/connectionCreationForm.tsx rename to packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx index 1fd27ee3..76c19a11 100644 --- a/packages/web/src/app/[domain]/connections/new/[type]/components/connectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx @@ -9,16 +9,17 @@ import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { isServiceError } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { Schema } from "ajv"; -import { useRouter } from "next/navigation"; import { useCallback, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { ConfigEditor, QuickActionFn } from "../../../components/configEditor"; +import { ConfigEditor, QuickActionFn } from "../configEditor"; import { useDomain } from "@/hooks/useDomain"; +import { Loader2 } from "lucide-react"; -interface ConnectionCreationForm { +interface SharedConnectionCreationFormProps { type: 'github' | 'gitlab' | 'gitea' | 'gerrit'; defaultValues: { name: string; @@ -30,18 +31,21 @@ interface ConnectionCreationForm { name: string; fn: QuickActionFn; }[], + className?: string; + onCreated?: (id: number) => void; } -export default function ConnectionCreationForm({ +export default function SharedConnectionCreationForm({ type, defaultValues, title, schema, quickActions, -}: ConnectionCreationForm) { + className, + onCreated, +}: SharedConnectionCreationFormProps) { const { toast } = useToast(); - const router = useRouter(); const domain = useDomain(); const formSchema = useMemo(() => { @@ -55,26 +59,24 @@ export default function ConnectionCreationForm({ resolver: zodResolver(formSchema), defaultValues: defaultValues, }); + const { isSubmitting } = form.formState; - const onSubmit = useCallback((data: z.infer) => { - createConnection(data.name, type, data.config, domain) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to create connection. Reason: ${response.message}` - }); - } else { - toast({ - description: `✅ Connection created successfully.` - }); - router.push(`/${domain}/connections`); - router.refresh(); - } + const onSubmit = useCallback(async (data: z.infer) => { + const response = await createConnection(data.name, type, data.config, domain); + if (isServiceError(response)) { + toast({ + description: `❌ Failed to create connection. Reason: ${response.message}` }); - }, [domain, router, toast, type]); + } else { + toast({ + description: `✅ Connection created successfully.` + }); + onCreated?.(response.id); + } + }, [domain, toast, type, onCreated]); return ( -
+
({ }} />
- +
diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx index 68264acf..f78c975e 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -89,7 +89,7 @@ export const NavigationMenu = async ({ - {!isServiceError(subscription) && subscription.status === "trialing" && ( + {!isServiceError(subscription) && subscription && subscription.status === "trialing" && (
diff --git a/packages/web/src/app/[domain]/components/onboardGuard.tsx b/packages/web/src/app/[domain]/components/onboardGuard.tsx new file mode 100644 index 00000000..a1c5aca3 --- /dev/null +++ b/packages/web/src/app/[domain]/components/onboardGuard.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { Redirect } from "@/app/components/redirect"; +import { useDomain } from "@/hooks/useDomain"; +import { usePathname } from "next/navigation"; +import { useMemo } from "react"; + +interface OnboardGuardProps { + children: React.ReactNode; +} + +export const OnboardGuard = ({ children }: OnboardGuardProps) => { + const domain = useDomain(); + const pathname = usePathname(); + + const content = useMemo(() => { + if (!pathname.endsWith('/onboard')) { + return ( + + ) + } else { + return children; + } + }, [domain, children, pathname]); + + return content; +} + + diff --git a/packages/web/src/app/[domain]/components/orgSelector/orgSelectorDropdown.tsx b/packages/web/src/app/[domain]/components/orgSelector/orgSelectorDropdown.tsx index 5659a4ee..42c1ff15 100644 --- a/packages/web/src/app/[domain]/components/orgSelector/orgSelectorDropdown.tsx +++ b/packages/web/src/app/[domain]/components/orgSelector/orgSelectorDropdown.tsx @@ -1,9 +1,10 @@ 'use client'; + import { useToast } from "@/components/hooks/use-toast"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { CaretSortIcon, CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons"; import { useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { OrgIcon } from "./orgIcon"; @@ -108,6 +109,20 @@ export const OrgSelectorDropdown = ({ + {searchFilter.length === 0 && ( + + + + + )} ); diff --git a/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx b/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx deleted file mode 100644 index 00e49f7e..00000000 --- a/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import { getSubscriptionCheckoutRedirect } from "@/actions" -import { isServiceError } from "@/lib/utils" - - -export function CheckoutButton({ domain }: { domain: string }) { - const redirectToCheckout = async () => { - const redirectUrl = await getSubscriptionCheckoutRedirect(domain) - - if (isServiceError(redirectUrl)) { - console.error("Failed to create checkout session") - return - } - - window.location.href = redirectUrl!; - } - - return ( - - ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/payWall/enterpriseContactUsButton.tsx b/packages/web/src/app/[domain]/components/payWall/enterpriseContactUsButton.tsx deleted file mode 100644 index 1fd6bf08..00000000 --- a/packages/web/src/app/[domain]/components/payWall/enterpriseContactUsButton.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" - -export function EnterpriseContactUsButton() { - const handleContactUs = () => { - window.location.href = "mailto:team@sourcebot.dev?subject=Enterprise%20Pricing%20Inquiry" - } - - return ( - - ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx deleted file mode 100644 index 0e58ef6f..00000000 --- a/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" -import { Check } from "lucide-react" -import { EnterpriseContactUsButton } from "./enterpriseContactUsButton" -import { CheckoutButton } from "./checkoutButton" -import { SourcebotLogo } from "@/app/components/sourcebotLogo"; - -const teamFeatures = [ - "Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported", - "Public and private repos supported", - "Create sharable links to code snippets", - "9x5 email support team@sourcebot.dev", -] - -const enterpriseFeatures = [ - "All Team features", - "Dedicated Slack support channel", - "Single tenant deployment", - "Advanced security features", -] - -export async function PaywallCard({ domain }: { domain: string }) { - return ( -
-
- -
-

- Your subscription has expired. -

-
- - - Team - For professional developers and small teams - - -
-

$10

-

per user / month

-
-
    - {teamFeatures.map((feature, index) => ( -
  • - - {feature} -
  • - ))} -
-
- - - -
- - - Enterprise - For large organizations with custom needs - - -
-

Custom

-

tailored to your needs

-
-
    - {enterpriseFeatures.map((feature, index) => ( -
  • - - {feature} -
  • - ))} -
-
- - - -
-
-
- ) -} diff --git a/packages/web/src/app/[domain]/components/upgradeGuard.tsx b/packages/web/src/app/[domain]/components/upgradeGuard.tsx new file mode 100644 index 00000000..f948eafa --- /dev/null +++ b/packages/web/src/app/[domain]/components/upgradeGuard.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { Redirect } from "@/app/components/redirect"; +import { useDomain } from "@/hooks/useDomain"; +import { usePathname } from "next/navigation"; +import { useMemo } from "react"; + +interface UpgradeGuardProps { + children: React.ReactNode; +} + +export const UpgradeGuard = ({ children }: UpgradeGuardProps) => { + const domain = useDomain(); + const pathname = usePathname(); + + const content = useMemo(() => { + if (!pathname.endsWith('/upgrade')) { + return ( + + ) + } else { + return children; + } + }, [domain, children, pathname]); + + return content; +} + + diff --git a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx index 8d11f00c..786de4c2 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx @@ -8,7 +8,7 @@ import { Loader2 } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { ConfigEditor, QuickAction } from "../../components/configEditor"; +import { ConfigEditor, QuickAction } from "../../../components/configEditor"; import { createZodConnectionConfigValidator } from "../../utils"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; diff --git a/packages/web/src/app/[domain]/connections/new/[type]/page.tsx b/packages/web/src/app/[domain]/connections/new/[type]/page.tsx index b785ce55..3c3ea089 100644 --- a/packages/web/src/app/[domain]/connections/new/[type]/page.tsx +++ b/packages/web/src/app/[domain]/connections/new/[type]/page.tsx @@ -1,115 +1,38 @@ 'use client'; -import { gerritQuickActions, giteaQuickActions, githubQuickActions, gitlabQuickActions } from "../../quickActions"; -import ConnectionCreationForm from "./components/connectionCreationForm"; -import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; -import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; -import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type"; -import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; -import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; -import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; -import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; -import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { useRouter } from "next/navigation"; - +import { + GitHubConnectionCreationForm, + GitLabConnectionCreationForm, + GiteaConnectionCreationForm, + GerritConnectionCreationForm +} from "@/app/[domain]/components/connectionCreationForms"; +import { useCallback } from "react"; export default function NewConnectionPage({ params }: { params: { type: string } }) { const { type } = params; const router = useRouter(); + const onCreated = useCallback(() => { + router.push('/connections'); + }, [router]); + if (type === 'github') { - return ; + return ; } if (type === 'gitlab') { - return ; + return ; } if (type === 'gitea') { - return ; + return ; } if (type === 'gerrit') { - return ; + return ; } router.push('/connections'); } - -const GitLabCreationForm = () => { - const defaultConfig: GitlabConnectionConfig = { - type: 'gitlab', - } - - return ( - - type="gitlab" - title="Create a GitLab connection" - defaultValues={{ - config: JSON.stringify(defaultConfig, null, 2), - name: 'my-gitlab-connection', - }} - schema={gitlabSchema} - quickActions={gitlabQuickActions} - /> - ) -} - -const GitHubCreationForm = () => { - const defaultConfig: GithubConnectionConfig = { - type: 'github', - } - - return ( - - type="github" - title="Create a GitHub connection" - defaultValues={{ - config: JSON.stringify(defaultConfig, null, 2), - name: 'my-github-connection', - }} - schema={githubSchema} - quickActions={githubQuickActions} - /> - ) -} - -const GiteaCreationForm = () => { - const defaultConfig: GiteaConnectionConfig = { - type: 'gitea', - } - - return ( - - type="gitea" - title="Create a Gitea connection" - defaultValues={{ - config: JSON.stringify(defaultConfig, null, 2), - name: 'my-gitea-connection', - }} - schema={giteaSchema} - quickActions={giteaQuickActions} - /> - ) -} - -const GerritCreationForm = () => { - const defaultConfig: GerritConnectionConfig = { - type: 'gerrit', - url: "https://gerrit.example.com" - } - - return ( - - type="gerrit" - title="Create a Gerrit connection" - defaultValues={{ - config: JSON.stringify(defaultConfig, null, 2), - name: 'my-gerrit-connection', - }} - schema={gerritSchema} - quickActions={gerritQuickActions} - /> - ) -} diff --git a/packages/web/src/app/[domain]/connections/quickActions.ts b/packages/web/src/app/[domain]/connections/quickActions.ts index 65cb63af..565db033 100644 --- a/packages/web/src/app/[domain]/connections/quickActions.ts +++ b/packages/web/src/app/[domain]/connections/quickActions.ts @@ -1,6 +1,6 @@ import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type" import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; -import { QuickAction } from "./components/configEditor"; +import { QuickAction } from "../components/configEditor"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type"; diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 7b665670..ace64db7 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -2,11 +2,10 @@ import { prisma } from "@/prisma"; import { PageNotFound } from "./components/pageNotFound"; import { auth } from "@/auth"; import { getOrgFromDomain } from "@/data/org"; -import { fetchSubscription } from "@/actions"; import { isServiceError } from "@/lib/utils"; -import { PaywallCard } from "./components/payWall/paywallCard"; -import { NavigationMenu } from "./components/navigationMenu"; -import { Footer } from "./components/footer"; +import { OnboardGuard } from "./components/onboardGuard"; +import { fetchSubscription } from "@/actions"; +import { UpgradeGuard } from "./components/upgradeGuard"; interface LayoutProps { children: React.ReactNode, @@ -43,14 +42,26 @@ export default async function Layout({ return } + if (!org.isOnboarded) { + return ( + + {children} + + ) + } + const subscription = await fetchSubscription(domain); - if (isServiceError(subscription) || (subscription.status !== "active" && subscription.status !== "trialing")) { + if ( + subscription && + ( + isServiceError(subscription) || + (subscription.status !== "active" && subscription.status !== "trialing") + ) + ) { return ( -
- - -
-
+ + {children} + ) } diff --git a/packages/web/src/app/[domain]/onboard/components/checkout.tsx b/packages/web/src/app/[domain]/onboard/components/checkout.tsx new file mode 100644 index 00000000..ebe31eaf --- /dev/null +++ b/packages/web/src/app/[domain]/onboard/components/checkout.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { createOnboardingStripeCheckoutSession } from "@/actions"; +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { useToast } from "@/components/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { useDomain } from "@/hooks/useDomain"; +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { ErrorCode } from "@/lib/errorCodes"; +import { isServiceError } from "@/lib/utils"; +import { Check, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { TEAM_FEATURES } from "@/lib/constants"; + +export const Checkout = () => { + const domain = useDomain(); + const { toast } = useToast(); + const errorCode = useNonEmptyQueryParam('errorCode'); + const errorMessage = useNonEmptyQueryParam('errorMessage'); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + useEffect(() => { + if (errorCode === ErrorCode.STRIPE_CHECKOUT_ERROR && errorMessage) { + toast({ + description: `⚠️ Stripe checkout failed with error: ${errorMessage}`, + variant: "destructive", + }); + } + }, [errorCode, errorMessage, toast]); + + const onCheckout = useCallback(() => { + setIsLoading(true); + createOnboardingStripeCheckoutSession(domain) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `❌ Stripe checkout failed with error: ${response.message}`, + variant: "destructive", + }) + } else { + router.push(response.url); + } + }) + .finally(() => { + setIsLoading(false); + }); + }, [domain, router, toast]); + + return ( +
+ +

Start your 7 day free trial

+

Cancel anytime. No credit card required.

+
    + {TEAM_FEATURES.map((feature, index) => ( +
  • +
    + +
    +

    {feature}

    +
  • + ))} +
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx b/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx new file mode 100644 index 00000000..267a5ecc --- /dev/null +++ b/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx @@ -0,0 +1,27 @@ +import { completeOnboarding } from "@/actions"; +import { OnboardingSteps } from "@/lib/constants"; +import { isServiceError } from "@/lib/utils"; +import { redirect } from "next/navigation"; + +interface CompleteOnboardingProps { + searchParams: { + stripe_session_id?: string; + } + params: { + domain: string; + } +} + +export const CompleteOnboarding = async ({ searchParams, params: { domain } }: CompleteOnboardingProps) => { + if (!searchParams.stripe_session_id) { + return redirect(`/${domain}/onboard?step=${OnboardingSteps.Checkout}`); + } + const { stripe_session_id } = searchParams; + + const response = await completeOnboarding(stripe_session_id, domain); + if (isServiceError(response)) { + return redirect(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`); + } + + return redirect(`/${domain}`); +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx new file mode 100644 index 00000000..b43494d5 --- /dev/null +++ b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx @@ -0,0 +1,114 @@ +'use client'; + +import Image from "next/image"; +import { useState } from "react"; +import { cn, CodeHostType } from "@/lib/utils"; +import { getCodeHostIcon } from "@/lib/utils"; +import { + GitHubConnectionCreationForm, + GitLabConnectionCreationForm, + GiteaConnectionCreationForm, + GerritConnectionCreationForm +} from "@/app/[domain]/components/connectionCreationForms"; +import { useRouter } from "next/navigation"; +import { useCallback } from "react"; +import { OnboardingSteps } from "@/lib/constants"; +import { Button } from "@/components/ui/button"; + +interface ConnectCodeHostProps { + nextStep: OnboardingSteps; +} + +export const ConnectCodeHost = ({ nextStep }: ConnectCodeHostProps) => { + const [selectedCodeHost, setSelectedCodeHost] = useState(null); + const router = useRouter(); + const onCreated = useCallback(() => { + router.push(`?step=${nextStep}`); + }, [nextStep, router]); + + if (!selectedCodeHost) { + return ( + + ) + } + + if (selectedCodeHost === "github") { + return ( + + ) + } + + if (selectedCodeHost === "gitlab") { + return ( + + ) + } + + if (selectedCodeHost === "gitea") { + return ( + + ) + } + + if (selectedCodeHost === "gerrit") { + return ( + + ) + } + + return null; +} + +interface CodeHostSelectionProps { + onSelect: (codeHost: CodeHostType) => void; +} + +const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => { + return ( +
+ onSelect("github")} + /> + onSelect("gitlab")} + /> + onSelect("gitea")} + /> + onSelect("gerrit")} + /> +
+ ) +} + +interface CodeHostButtonProps { + name: string; + logo: { src: string, className?: string }; + onClick: () => void; +} + +const CodeHostButton = ({ + name, + logo, + onClick, +}: CodeHostButtonProps) => { + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx b/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx new file mode 100644 index 00000000..ab16a7c7 --- /dev/null +++ b/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { createInvites } from "@/actions"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { isServiceError } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, PlusCircleIcon } from "lucide-react"; +import { useCallback } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { inviteMemberFormSchema } from "../../settings/members/components/inviteMemberCard"; +import { useDomain } from "@/hooks/useDomain"; +import { useToast } from "@/components/hooks/use-toast"; +import { OnboardingSteps } from "@/lib/constants"; +import { useRouter } from "next/navigation"; + +interface InviteTeamProps { + nextStep: OnboardingSteps; +} + +export const InviteTeam = ({ nextStep }: InviteTeamProps) => { + const domain = useDomain(); + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(inviteMemberFormSchema), + defaultValues: { + emails: [{ email: "" }] + }, + }); + + const addEmailField = useCallback(() => { + const emails = form.getValues().emails; + form.setValue('emails', [...emails, { email: "" }]); + }, [form]); + + const onComplete = useCallback(() => { + router.push(`?step=${nextStep}`); + }, [nextStep, router]); + + const onSubmit = useCallback(async (data: z.infer) => { + const response = await createInvites(data.emails.map(e => e.email), domain); + if (isServiceError(response)) { + toast({ + description: `❌ Failed to invite members. Reason: ${response.message}` + }); + } else { + toast({ + description: `✅ Successfully invited ${data.emails.length} members` + }); + onComplete(); + } + }, [domain, toast, onComplete]); + + const onSkip = useCallback(() => { + onComplete(); + }, [onComplete]); + + return ( + +
+ + + Email Address + {form.watch('emails').map((_, index) => ( + ( + + + + + + + )} + /> + ))} + {form.formState.errors.emails?.root?.message && ( + {form.formState.errors.emails.root.message} + )} + + + + + + +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/page.tsx b/packages/web/src/app/[domain]/onboard/page.tsx new file mode 100644 index 00000000..e4c2addc --- /dev/null +++ b/packages/web/src/app/[domain]/onboard/page.tsx @@ -0,0 +1,90 @@ +import { OnboardHeader } from "@/app/onboard/components/onboardHeader"; +import { getOrgFromDomain } from "@/data/org"; +import { OnboardingSteps } from "@/lib/constants"; +import { notFound, redirect } from "next/navigation"; +import { ConnectCodeHost } from "./components/connectCodeHost"; +import { InviteTeam } from "./components/inviteTeam"; +import Link from "next/link"; +import { CompleteOnboarding } from "./components/completeOnboarding"; +import { Checkout } from "./components/checkout"; +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; + +interface OnboardProps { + params: { + domain: string + }, + searchParams: { + step?: string + stripe_session_id?: string + } +} + +export default async function Onboard({ params, searchParams }: OnboardProps) { + const org = await getOrgFromDomain(params.domain); + if (!org) { + notFound(); + } + + if (org.isOnboarded) { + redirect(`/${params.domain}`); + } + + const step = searchParams.step ?? OnboardingSteps.ConnectCodeHost; + if ( + !Object.values(OnboardingSteps) + .filter(s => s !== OnboardingSteps.CreateOrg) + .map(s => s.toString()) + .includes(step) + ) { + redirect(`/${params.domain}/onboard?step=${OnboardingSteps.ConnectCodeHost}`); + } + + const lastRequiredStep = OnboardingSteps.Checkout; + + return ( +
+ + {step === OnboardingSteps.ConnectCodeHost && ( + <> + + + + Skip onboarding + + + )} + {step === OnboardingSteps.InviteTeam && ( + <> + + + + )} + {step === OnboardingSteps.Checkout && ( + <> + + + )} + {step === OnboardingSteps.Complete && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx index b644d896..82fbe37e 100644 --- a/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx +++ b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx @@ -83,8 +83,8 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa Email address - )} /> - +
+ +
diff --git a/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx b/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx index 47c263ba..acc017a0 100644 --- a/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx +++ b/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx @@ -31,13 +31,14 @@ export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: const isOwner = currentUserRole === OrgRole.OWNER return ( - - ) +
+ +
+ ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index 0cc24533..801a1067 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -29,6 +29,10 @@ export default async function BillingPage({ return
Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.
} + if (!subscription) { + return
todo
+ } + const currentUserRole = await getCurrentUserRole(domain) if (isServiceError(currentUserRole)) { return
Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.
diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 52717ca7..4c1720d1 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -13,6 +13,7 @@ export default function SettingsLayout({ children: React.ReactNode; params: { domain: string }; }>) { + const sidebarNavItems = [ { title: "General", diff --git a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx index a3d77eb9..23f974f2 100644 --- a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx @@ -17,7 +17,7 @@ import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; -const formSchema = z.object({ +export const inviteMemberFormSchema = z.object({ emails: z.array(z.object({ email: z.string().email() })) @@ -38,8 +38,8 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) => const { toast } = useToast(); const router = useRouter(); - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm>({ + resolver: zodResolver(inviteMemberFormSchema), defaultValues: { emails: [{ email: "" }] }, @@ -50,7 +50,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) => form.setValue('emails', [...emails, { email: "" }]); }, [form]); - const onSubmit = useCallback((data: z.infer) => { + const onSubmit = useCallback((data: z.infer) => { setIsLoading(true); createInvites(data.emails.map(e => e.email), domain) .then((res) => { diff --git a/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx b/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx new file mode 100644 index 00000000..0a08024a --- /dev/null +++ b/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { ENTERPRISE_FEATURES } from "@/lib/constants"; +import { UpgradeCard } from "./upgradeCard"; +import Link from "next/link"; + + +export const EnterpriseUpgradeCard = () => { + return ( + + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx b/packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx new file mode 100644 index 00000000..6a666324 --- /dev/null +++ b/packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { UpgradeCard } from "./upgradeCard"; +import { createStripeCheckoutSession } from "@/actions"; +import { useToast } from "@/components/hooks/use-toast"; +import { useDomain } from "@/hooks/useDomain"; +import { isServiceError } from "@/lib/utils"; +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { TEAM_FEATURES } from "@/lib/constants"; + +interface TeamUpgradeCardProps { + buttonText: string; +} + +export const TeamUpgradeCard = ({ buttonText }: TeamUpgradeCardProps) => { + const domain = useDomain(); + const { toast } = useToast(); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + + const onClick = useCallback(() => { + setIsLoading(true); + createStripeCheckoutSession(domain) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `❌ Stripe checkout failed with error: ${response.message}`, + variant: "destructive", + }); + } else { + router.push(response.url); + } + }) + .finally(() => { + setIsLoading(false); + }); + }, [domain, router, toast]); + + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/upgrade/components/upgradeCard.tsx b/packages/web/src/app/[domain]/upgrade/components/upgradeCard.tsx new file mode 100644 index 00000000..9d24b254 --- /dev/null +++ b/packages/web/src/app/[domain]/upgrade/components/upgradeCard.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Check, Loader2 } from "lucide-react"; + + +interface UpgradeCardProps { + title: string; + description: string; + price: string; + priceDescription: string; + features: string[]; + buttonText: string; + onClick?: () => void; + isLoading?: boolean; +} + +export const UpgradeCard = ({ title, description, price, priceDescription, features, buttonText, onClick, isLoading = false }: UpgradeCardProps) => { + return ( + onClick?.()} + > + + {title} + {description} + + +
+

{price}

+

{priceDescription}

+
+
    + {features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ + + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/upgrade/page.tsx b/packages/web/src/app/[domain]/upgrade/page.tsx new file mode 100644 index 00000000..ace24b81 --- /dev/null +++ b/packages/web/src/app/[domain]/upgrade/page.tsx @@ -0,0 +1,69 @@ +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { Footer } from "../components/footer"; +import { OrgSelector } from "../components/orgSelector"; +import { EnterpriseUpgradeCard } from "./components/enterpriseUpgradeCard"; +import { TeamUpgradeCard } from "./components/teamUpgradeCard"; +import { fetchSubscription } from "@/actions"; +import { redirect } from "next/navigation"; +import { isServiceError } from "@/lib/utils"; +import Link from "next/link"; +import { ArrowLeftIcon } from "@radix-ui/react-icons"; +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; + +export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) { + + const subscription = await fetchSubscription(domain); + if (!subscription) { + redirect(`/${domain}`); + } + + if (!isServiceError(subscription) && subscription.status === "active") { + redirect(`/${domain}`); + } + + const isTrialing = !isServiceError(subscription) ? subscription.status === "trialing" : false; + + return ( +
+ {isTrialing && ( + +
+ Return to dashboard +
+ + )} + +
+ +

+ {isTrialing ? + "Upgrade your trial." : + "Your subscription has expired." + } +

+

+ {isTrialing ? + "Upgrade now to get the most out of Sourcebot." : + "Please upgrade to continue using Sourcebot." + } +

+
+ + + +
+ + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts index 5a2b0cad..77c1a15e 100644 --- a/packages/web/src/app/api/(server)/stripe/route.ts +++ b/packages/web/src/app/api/(server)/stripe/route.ts @@ -5,6 +5,7 @@ import { prisma } from '@/prisma'; import { STRIPE_WEBHOOK_SECRET } from '@/lib/environment'; import { getStripe } from '@/lib/stripe'; import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db'; + export async function POST(req: NextRequest) { const body = await req.text(); const signature = headers().get('stripe-signature'); diff --git a/packages/web/src/app/components/logoutEscapeHatch.tsx b/packages/web/src/app/components/logoutEscapeHatch.tsx new file mode 100644 index 00000000..7bccb6be --- /dev/null +++ b/packages/web/src/app/components/logoutEscapeHatch.tsx @@ -0,0 +1,31 @@ +import { LogOutIcon } from "lucide-react"; +import { signOut } from "@/auth"; +import { cn } from "@/lib/utils"; +interface LogoutEscapeHatchProps { + className?: string; +} + +export const LogoutEscapeHatch = ({ + className, +}: LogoutEscapeHatchProps) => { + return ( +
+
{ + "use server"; + await signOut({ + redirectTo: "/login", + }); + }} + > + +
+
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/components/redirect.tsx b/packages/web/src/app/components/redirect.tsx new file mode 100644 index 00000000..cefea5ee --- /dev/null +++ b/packages/web/src/app/components/redirect.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export const Redirect = ({ + to, +}: { + to: string; +}) => { + const router = useRouter(); + + useEffect(() => { + router.push(to); + }, [router, to]); + + return null; +} \ No newline at end of file diff --git a/packages/web/src/app/components/textSeparator.tsx b/packages/web/src/app/components/textSeparator.tsx new file mode 100644 index 00000000..73211ae6 --- /dev/null +++ b/packages/web/src/app/components/textSeparator.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils" + + +interface TextSeparatorProps { + className?: string; + text?: string; +} + +export const TextSeparator = ({ className, text = "or" }: TextSeparatorProps) => { + return ( +
+
+ {text} +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/login/components/loginForm.tsx b/packages/web/src/app/login/components/loginForm.tsx index 6a67c54b..a8829b01 100644 --- a/packages/web/src/app/login/components/loginForm.tsx +++ b/packages/web/src/app/login/components/loginForm.tsx @@ -10,6 +10,7 @@ import { cn, getCodeHostIcon } from "@/lib/utils"; import { MagicLinkForm } from "./magicLinkForm"; import { CredentialsForm } from "./credentialsForm"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { TextSeparator } from "@/app/components/textSeparator"; interface LoginFormProps { callbackUrl?: string; @@ -122,18 +123,8 @@ const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => { return ( {child} - {index < elements.length - 1 && } + {index < elements.length - 1 && } ) }) } - -const Divider = ({ className }: { className?: string }) => { - return ( -
-
- or -
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx index 1ac6bd8a..c113fa0a 100644 --- a/packages/web/src/app/login/page.tsx +++ b/packages/web/src/app/login/page.tsx @@ -27,7 +27,7 @@ export default async function Login({ searchParams }: LoginProps) { }); return ( -
+
+
; - } - - const stripeSession = await fetchStripeSession(sessionId); - if(stripeSession.payment_status !== "paid") { - console.error("Invalid stripe session"); - return ; - } - - const stripeCustomerId = stripeSession.customer as string; - const res = await createOrg(orgName, orgDomain, stripeCustomerId); - if (isServiceError(res)) { - console.error("Failed to create org"); - return ; - } - - redirect("/"); -} \ No newline at end of file diff --git a/packages/web/src/app/onboard/components/errorPage.tsx b/packages/web/src/app/onboard/components/errorPage.tsx deleted file mode 100644 index 55c250d2..00000000 --- a/packages/web/src/app/onboard/components/errorPage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client" - -import { useRouter } from "next/navigation" -import { XCircle } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" - -export function ErrorPage() { - const router = useRouter() - - return ( -
- - -
- -
-

Organization Creation Failed

-

- We encountered an error while creating your organization. Please try again. -

-

- If the problem persists, please contact us at team@sourcebot.dev -

- -
-
-
- ) -} - diff --git a/packages/web/src/app/onboard/components/onboardHeader.tsx b/packages/web/src/app/onboard/components/onboardHeader.tsx new file mode 100644 index 00000000..a9d95004 --- /dev/null +++ b/packages/web/src/app/onboard/components/onboardHeader.tsx @@ -0,0 +1,35 @@ +import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import { OnboardingSteps } from "@/lib/constants"; + +interface OnboardHeaderProps { + title: string + description: string + step: OnboardingSteps +} + +export const OnboardHeader = ({ title, description, step: currentStep }: OnboardHeaderProps) => { + const steps = Object.values(OnboardingSteps).filter(s => s !== OnboardingSteps.Complete); + + return ( +
+ +

+ {title} +

+

+ {description} +

+
+ {steps.map((step, index) => ( +
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/onboard/components/orgCreateForm.tsx b/packages/web/src/app/onboard/components/orgCreateForm.tsx index 6d7c7d8b..a56c5b10 100644 --- a/packages/web/src/app/onboard/components/orgCreateForm.tsx +++ b/packages/web/src/app/onboard/components/orgCreateForm.tsx @@ -1,15 +1,19 @@ "use client" -import { checkIfOrgDomainExists } from "../../../actions" +import { checkIfOrgDomainExists, createOrg } from "../../../actions" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form" -import { isServiceError } from "@/lib/utils" import { useForm } from "react-hook-form" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" -import { useState } from "react"; +import { useCallback } from "react"; import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import { isServiceError } from "@/lib/utils" +import { Loader2 } from "lucide-react" +import { useToast } from "@/components/hooks/use-toast" +import { useRouter } from "next/navigation"; +import { Card } from "@/components/ui/card" const onboardingFormSchema = z.object({ name: z.string() @@ -20,55 +24,46 @@ const onboardingFormSchema = z.object({ .max(20, { message: "Organization domain must be at most 20 characters long." }) .regex(/^[a-z][a-z-]*[a-z]$/, { message: "Domain must start and end with a letter, and can only contain lowercase letters and dashes.", - }), + }) + .refine(async (domain) => { + const doesDomainExist = await checkIfOrgDomainExists(domain); + return isServiceError(doesDomainExist) || !doesDomainExist; + }, "This domain is already taken."), }) -export type OnboardingFormValues = z.infer - -const defaultValues: Partial = { - name: "", - domain: "", -} - -interface OrgCreateFormProps { - setOrgCreateData: (data: OnboardingFormValues) => void; -} - -export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) { - const form = useForm({ resolver: zodResolver(onboardingFormSchema), defaultValues }) - const [errorMessage, setErrorMessage] = useState(null); - - async function submitOrgInfoForm(data: OnboardingFormValues) { - const res = await checkIfOrgDomainExists(data.domain); - if (isServiceError(res)) { - setErrorMessage("An error occurred while checking the domain. Please try clearing your cookies and trying again."); - return; +export function OrgCreateForm() { + const { toast } = useToast(); + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(onboardingFormSchema), + defaultValues: { + name: "", + domain: "", } + }); + const { isSubmitting } = form.formState; - if (res) { - setErrorMessage("Organization domain already exists. Please try a different one."); - return; + const onSubmit = useCallback(async (data: z.infer) => { + const response = await createOrg(data.name, data.domain); + if (isServiceError(response)) { + toast({ + description: `❌ Failed to create organization. Reason: ${response.message}` + }) } else { - setOrgCreateData(data); + router.push(`/${data.domain}/onboard`); } - } + }, [router, toast]); const handleNameChange = (e: React.ChangeEvent) => { const name = e.target.value const domain = name.toLowerCase().replace(/\s+/g, "-") form.setValue("domain", domain) - } + } return ( -
-
- -
-

Let's create your organization

+
- + Organization Name - { field.onChange(e) handleNameChange(e) @@ -105,12 +101,17 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) { )} /> - {errorMessage &&

{errorMessage}

} -
- -
+ -
+ ) } diff --git a/packages/web/src/app/onboard/components/trialInfoCard.tsx b/packages/web/src/app/onboard/components/trialInfoCard.tsx deleted file mode 100644 index e5d34837..00000000 --- a/packages/web/src/app/onboard/components/trialInfoCard.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@/components/ui/card"; -import { Check } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -import { setupInitialStripeCustomer } from "../../../actions" -import { - EmbeddedCheckout, - EmbeddedCheckoutProvider - } from '@stripe/react-stripe-js' - import { loadStripe } from '@stripe/stripe-js' -import { useState } from "react"; -import { OnboardingFormValues } from "./orgCreateForm"; -import { isServiceError } from "@/lib/utils"; -import { NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY } from "@/lib/environment.client"; -import { SourcebotLogo } from "@/app/components/sourcebotLogo"; - -const stripePromise = loadStripe(NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) - -export function TrialCard({ orgCreateInfo }: { orgCreateInfo: OnboardingFormValues }) { - const [trialAck, setTrialAck] = useState(false); - - return ( -
- {trialAck ? ( -
- { - const clientSecret = await setupInitialStripeCustomer(orgCreateInfo.name, orgCreateInfo.domain); - if (isServiceError(clientSecret)) { - throw clientSecret; - } - return clientSecret; - } }} - > - - -
- ) : - - -
- -
- 7 day free trial - Cancel anytime. No credit card required. -
- -
    - {[ - "Blazingly fast code search", - "Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported.", - "Public and private repos supported.", - "Create sharable links to code snippets.", - "Powerful regex and symbol search", - ].map((feature, index) => ( -
  • -
    - -
    -

    {feature}

    -
  • - ))} -
-
- -
-
-
- } -
- ) -} diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index a3a2bfcd..ad0b369c 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -1,35 +1,25 @@ -"use client"; +import { OrgCreateForm } from "./components/orgCreateForm"; +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { OnboardHeader } from "./components/onboardHeader"; +import { OnboardingSteps } from "@/lib/constants"; +import { LogoutEscapeHatch } from "../components/logoutEscapeHatch"; -import { useState, useEffect} from "react"; -import { OrgCreateForm, OnboardingFormValues } from "./components/orgCreateForm"; -import { TrialCard } from "./components/trialInfoCard"; -import { isAuthed } from "@/actions"; -import { useRouter } from "next/navigation"; - -export default function Onboarding() { - const router = useRouter(); - const [orgCreateInfo, setOrgInfo] = useState(undefined); - - useEffect(() => { - const redirectIfNotAuthed = async () => { - const authed = await isAuthed(); - if(!authed) { - router.push("/login"); - } - } - - redirectIfNotAuthed(); - }, [router]); +export default async function Onboarding() { + const session = await auth(); + if (!session) { + redirect("/login"); + } return ( -
- {orgCreateInfo ? ( - - ) : ( -
- -
- )} +
+ + +
); } diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx index 50d9e216..e87932ac 100644 --- a/packages/web/src/app/redeem/page.tsx +++ b/packages/web/src/app/redeem/page.tsx @@ -74,16 +74,6 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) { ) } - const stripeCustomerId = org.stripeCustomerId; - if (stripeCustomerId) { - const subscription = await fetchSubscription(org.domain); - if (isServiceError(subscription)) { - return ( - - ) - } - } - return (
diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts new file mode 100644 index 00000000..3aac5095 --- /dev/null +++ b/packages/web/src/lib/constants.ts @@ -0,0 +1,24 @@ + +// @note: Order is important here. +export enum OnboardingSteps { + CreateOrg = 'create-org', + ConnectCodeHost = 'connect-code-host', + InviteTeam = 'invite-team', + Checkout = 'checkout', + Complete = 'complete', +} + +export const ENTERPRISE_FEATURES = [ + "All Team features", + "Dedicated Slack support channel", + "Single tenant deployment", + "Advanced security features", +] + +export const TEAM_FEATURES = [ + "Blazingly fast code search", + "Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code hosts supported.", + "Public and private repos supported.", + "Create sharable links to code snippets.", + "Powerful regex and symbol search", +] \ No newline at end of file diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 552ee360..d1666ce4 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -16,4 +16,5 @@ export enum ErrorCode { CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED', OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG', INVALID_INVITE = 'INVALID_INVITE', + STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR', }