Refined onboarding flow (#202)

This commit is contained in:
Brendan Kellam 2025-02-21 10:32:10 -08:00 committed by GitHub
parent a79c162d9c
commit fee0767981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1360 additions and 665 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Org" ADD COLUMN "isOnboarded" BOOLEAN NOT NULL DEFAULT false;

View file

@ -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[]

View file

@ -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",

View file

@ -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 <T>(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<OrgRole | Serv
withAuth((session) =>
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,
},
});
const stripe = getStripe();
const origin = (await headers()).get('origin')
if (!org) {
return notFound();
}
// @nocheckin
const test_clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000)
})
const user = await getUser(session.user.id);
if (!user) {
return notFound();
}
const customer = await stripe.customers.create({
name: user.name!,
email: user.email!,
test_clock: test_clock.id
})
const stripe = getStripe();
const origin = (await headers()).get('origin');
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
// @nocheckin
const test_clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000)
});
// 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;
}
export const getSubscriptionCheckoutRedirect = async (domain: string) =>
return {
url: stripeSession.url,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
);
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<string | ServiceError> =>
withAuth((session) =>
@ -874,29 +962,39 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise<stri
}, /* minRequiredRole = */ OrgRole.OWNER)
);
export const fetchSubscription = (domain: string): Promise<Stripe.Subscription | ServiceError> =>
withAuth(async () => {
const org = await prisma.org.findUnique({
where: {
domain,
},
});
export const fetchSubscription = (domain: string): Promise<Stripe.Subscription | null | ServiceError> =>
withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
return _fetchSubscriptionForOrg(orgId, prisma);
})
);
if (!org || !org.stripeCustomerId) {
return notFound();
}
const stripe = getStripe();
const subscriptions = await stripe.subscriptions.list({
customer: org.stripeCustomerId
});
if (subscriptions.data.length === 0) {
return notFound();
}
return subscriptions.data[0];
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | null | ServiceError> => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org) {
return notFound();
}
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<string | ServiceError> =>
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 {

View file

@ -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 (
<SharedConnectionCreationForm<GerritConnectionConfig>
type="gerrit"
title="Create a Gerrit connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
name: 'my-gerrit-connection',
}}
schema={gerritSchema}
quickActions={gerritQuickActions}
onCreated={onCreated}
/>
)
}

View file

@ -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 (
<SharedConnectionCreationForm<GiteaConnectionConfig>
type="gitea"
title="Create a Gitea connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
name: 'my-gitea-connection',
}}
schema={giteaSchema}
quickActions={giteaQuickActions}
onCreated={onCreated}
/>
)
}

View file

@ -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 (
<SharedConnectionCreationForm<GithubConnectionConfig>
type="github"
title="Create a GitHub connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
name: 'my-github-connection',
}}
schema={githubSchema}
quickActions={githubQuickActions}
onCreated={onCreated}
/>
)
}

View file

@ -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 (
<SharedConnectionCreationForm<GitlabConnectionConfig>
type="gitlab"
title="Create a GitLab connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
name: 'my-gitlab-connection',
}}
schema={gitlabSchema}
quickActions={gitlabQuickActions}
onCreated={onCreated}
/>
)
}

View file

@ -0,0 +1,4 @@
export { GitHubConnectionCreationForm } from "./githubConnectionCreationForm";
export { GitLabConnectionCreationForm } from "./gitlabConnectionCreationForm";
export { GiteaConnectionCreationForm } from "./giteaConnectionCreationForm";
export { GerritConnectionCreationForm } from "./gerritConnectionCreationForm";

View file

@ -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<T> {
interface SharedConnectionCreationFormProps<T> {
type: 'github' | 'gitlab' | 'gitea' | 'gerrit';
defaultValues: {
name: string;
@ -30,18 +31,21 @@ interface ConnectionCreationForm<T> {
name: string;
fn: QuickActionFn<T>;
}[],
className?: string;
onCreated?: (id: number) => void;
}
export default function ConnectionCreationForm<T>({
export default function SharedConnectionCreationForm<T>({
type,
defaultValues,
title,
schema,
quickActions,
}: ConnectionCreationForm<T>) {
className,
onCreated,
}: SharedConnectionCreationFormProps<T>) {
const { toast } = useToast();
const router = useRouter();
const domain = useDomain();
const formSchema = useMemo(() => {
@ -55,26 +59,24 @@ export default function ConnectionCreationForm<T>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues,
});
const { isSubmitting } = form.formState;
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
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<typeof formSchema>) => {
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 (
<div className="flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6">
<div className={cn("flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6", className)}>
<div className="flex flex-row items-center gap-3 mb-6">
<ConnectionIcon
type={type}
@ -128,7 +130,14 @@ export default function ConnectionCreationForm<T>({
}}
/>
</div>
<Button className="mt-5" type="submit">Submit</Button>
<Button
className="mt-5"
type="submit"
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="animate-spin w-4 h-4 mr-2" />}
Submit
</Button>
</form>
</Form>
</div>

View file

@ -89,7 +89,7 @@ export const NavigationMenu = async ({
<ProgressNavIndicator />
<WarningNavIndicator />
<ErrorNavIndicator />
{!isServiceError(subscription) && subscription.status === "trialing" && (
{!isServiceError(subscription) && subscription && subscription.status === "trialing" && (
<Link href={`/${domain}/settings/billing`}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-full text-blue-700 dark:text-blue-400 text-xs font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
<span className="inline-block w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></span>

View file

@ -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 (
<Redirect
to={`/${domain}/onboard`}
/>
)
} else {
return children;
}
}, [domain, children, pathname]);
return content;
}

View file

@ -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 = ({
</CommandList>
</Command>
</DropdownMenuGroup>
{searchFilter.length === 0 && (
<DropdownMenuGroup>
<DropdownMenuSeparator />
<Button
variant="ghost"
size="default"
className="w-full justify-start gap-1.5 p-2"
onClick={() => router.push("/onboard")}
>
<PlusCircledIcon className="h-5 w-5 text-muted-foreground" />
Create new organization
</Button>
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
);

View file

@ -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 (
<Button className="w-full" onClick={redirectToCheckout}>Renew Membership</Button>
)
}

View file

@ -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 (
<Button className="w-full" onClick={handleContactUs}>
Contact Us
</Button>
)
}

View file

@ -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 (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="max-h-44 w-auto mb-4 flex justify-center">
<SourcebotLogo
className="h-18 md:h-40"
size="large"
/>
</div>
<h2 className="text-3xl font-bold text-center mb-8 text-primary">
Your subscription has expired.
</h2>
<div className="grid gap-8 md:grid-cols-2">
<Card className="border-2 border-primary/20 shadow-lg transition-all duration-300 hover:shadow-xl hover:border-primary/50 flex flex-col">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-primary">Team</CardTitle>
<CardDescription className="text-base">For professional developers and small teams</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<div className="mb-4">
<p className="text-4xl font-bold text-primary">$10</p>
<p className="text-sm text-muted-foreground">per user / month</p>
</div>
<ul className="space-y-3">
{teamFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<CheckoutButton domain={domain} />
</CardFooter>
</Card>
<Card className="border-2 border-primary/20 shadow-lg transition-all duration-300 hover:shadow-xl hover:border-primary/50 flex flex-col">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-primary">Enterprise</CardTitle>
<CardDescription className="text-base">For large organizations with custom needs</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<div className="mb-4">
<p className="text-4xl font-bold text-primary">Custom</p>
<p className="text-sm text-muted-foreground">tailored to your needs</p>
</div>
<ul className="space-y-3">
{enterpriseFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<EnterpriseContactUsButton />
</CardFooter>
</Card>
</div>
</div>
)
}

View file

@ -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 (
<Redirect
to={`/${domain}/upgrade`}
/>
)
} else {
return children;
}
}, [domain, children, pathname]);
return content;
}

View file

@ -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";

View file

@ -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 <GitHubCreationForm />;
return <GitHubConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'gitlab') {
return <GitLabCreationForm />;
return <GitLabConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'gitea') {
return <GiteaCreationForm />;
return <GiteaConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'gerrit') {
return <GerritCreationForm />;
return <GerritConnectionCreationForm onCreated={onCreated} />;
}
router.push('/connections');
}
const GitLabCreationForm = () => {
const defaultConfig: GitlabConnectionConfig = {
type: 'gitlab',
}
return (
<ConnectionCreationForm<GitlabConnectionConfig>
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 (
<ConnectionCreationForm<GithubConnectionConfig>
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 (
<ConnectionCreationForm<GiteaConnectionConfig>
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 (
<ConnectionCreationForm<GerritConnectionConfig>
type="gerrit"
title="Create a Gerrit connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
name: 'my-gerrit-connection',
}}
schema={gerritSchema}
quickActions={gerritQuickActions}
/>
)
}

View file

@ -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";

View file

@ -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 <PageNotFound />
}
const subscription = await fetchSubscription(domain);
if (isServiceError(subscription) || (subscription.status !== "active" && subscription.status !== "trialing")) {
if (!org.isOnboarded) {
return (
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<NavigationMenu domain={domain} />
<PaywallCard domain={domain} />
<Footer />
</div>
<OnboardGuard>
{children}
</OnboardGuard>
)
}
const subscription = await fetchSubscription(domain);
if (
subscription &&
(
isServiceError(subscription) ||
(subscription.status !== "active" && subscription.status !== "trialing")
)
) {
return (
<UpgradeGuard>
{children}
</UpgradeGuard>
)
}

View file

@ -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 (
<div className="flex flex-col items-center justify-center max-w-md my-auto">
<SourcebotLogo
className="h-16"
size="large"
/>
<h1 className="text-2xl font-semibold">Start your 7 day free trial</h1>
<p className="text-muted-foreground mt-2">Cancel anytime. No credit card required.</p>
<ul className="space-y-4 mb-6 mt-10">
{TEAM_FEATURES.map((feature, index) => (
<li key={index} className="flex items-center">
<div className="mr-3 flex-shrink-0">
<Check className="h-5 w-5 text-sky-500" />
</div>
<p className="text-gray-600 dark:text-gray-300">{feature}</p>
</li>
))}
</ul>
<div className="w-full px-16 mt-8">
<Button
className="w-full"
onClick={onCheckout}
disabled={isLoading}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Start free trial
</Button>
</div>
</div>
)
}

View file

@ -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}`);
}

View file

@ -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<CodeHostType | null>(null);
const router = useRouter();
const onCreated = useCallback(() => {
router.push(`?step=${nextStep}`);
}, [nextStep, router]);
if (!selectedCodeHost) {
return (
<CodeHostSelection onSelect={setSelectedCodeHost} />
)
}
if (selectedCodeHost === "github") {
return (
<GitHubConnectionCreationForm onCreated={onCreated} />
)
}
if (selectedCodeHost === "gitlab") {
return (
<GitLabConnectionCreationForm onCreated={onCreated} />
)
}
if (selectedCodeHost === "gitea") {
return (
<GiteaConnectionCreationForm onCreated={onCreated} />
)
}
if (selectedCodeHost === "gerrit") {
return (
<GerritConnectionCreationForm onCreated={onCreated} />
)
}
return null;
}
interface CodeHostSelectionProps {
onSelect: (codeHost: CodeHostType) => void;
}
const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
return (
<div className="flex flex-row gap-4">
<CodeHostButton
name="GitHub"
logo={getCodeHostIcon("github")!}
onClick={() => onSelect("github")}
/>
<CodeHostButton
name="GitLab"
logo={getCodeHostIcon("gitlab")!}
onClick={() => onSelect("gitlab")}
/>
<CodeHostButton
name="Gitea"
logo={getCodeHostIcon("gitea")!}
onClick={() => onSelect("gitea")}
/>
<CodeHostButton
name="Gerrit"
logo={getCodeHostIcon("gerrit")!}
onClick={() => onSelect("gerrit")}
/>
</div>
)
}
interface CodeHostButtonProps {
name: string;
logo: { src: string, className?: string };
onClick: () => void;
}
const CodeHostButton = ({
name,
logo,
onClick,
}: CodeHostButtonProps) => {
return (
<Button
className="flex flex-col items-center justify-center p-4 w-24 h-24 cursor-pointer gap-2"
variant="outline"
onClick={onClick}
>
<Image src={logo.src} alt={name} className={cn("w-8 h-8", logo.className)} />
<p className="text-sm font-medium">{name}</p>
</Button>
)
}

View file

@ -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<z.infer<typeof inviteMemberFormSchema>>({
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<typeof inviteMemberFormSchema>) => {
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 (
<Card className="p-12 w-[500px]">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<FormLabel>Email Address</FormLabel>
{form.watch('emails').map((_, index) => (
<FormField
key={index}
control={form.control}
name={`emails.${index}.email`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
placeholder="melissa@example.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
{form.formState.errors.emails?.root?.message && (
<FormMessage>{form.formState.errors.emails.root.message}</FormMessage>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={addEmailField}
>
<PlusCircleIcon className="w-4 h-4 mr-0.5" />
Add more
</Button>
</CardContent>
<CardFooter className="flex justify-end">
<Button
size="sm"
variant="outline"
className="mr-2"
type="button"
onClick={onSkip}
>
Skip for now
</Button>
<Button
size="sm"
type="submit"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting && <Loader2 className="w-4 h-4 mr-0.5 animate-spin" />}
Invite
</Button>
</CardFooter>
</form>
</Form>
</Card >
)
}

View file

@ -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 (
<div className="flex flex-col items-center p-12 min-h-screen bg-backgroundSecondary relative">
<LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
{step === OnboardingSteps.ConnectCodeHost && (
<>
<OnboardHeader
title="Connect your code host"
description="Connect your code host to start searching your code."
step={step as OnboardingSteps}
/>
<ConnectCodeHost
nextStep={OnboardingSteps.InviteTeam}
/>
<Link
className="text-sm text-muted-foreground underline cursor-pointer mt-12"
href={`?step=${lastRequiredStep}`}
>
Skip onboarding
</Link>
</>
)}
{step === OnboardingSteps.InviteTeam && (
<>
<OnboardHeader
title="Invite your team"
description="Invite your team to get the most out of Sourcebot."
step={step as OnboardingSteps}
/>
<InviteTeam
nextStep={lastRequiredStep}
/>
</>
)}
{step === OnboardingSteps.Checkout && (
<>
<Checkout />
</>
)}
{step === OnboardingSteps.Complete && (
<CompleteOnboarding
searchParams={searchParams}
params={params}
/>
)}
</div>
)
}

View file

@ -83,8 +83,8 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
<FormItem>
<FormLabel>Email address</FormLabel>
<FormControl>
<Input
placeholder={billingEmail}
<Input
placeholder={billingEmail}
{...field}
disabled={currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
@ -94,14 +94,15 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading || currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
>
{isLoading ? "Updating..." : "Update Billing Email"}
</Button>
<div className="flex justify-end">
<Button
type="submit"
disabled={isLoading || currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
>
{isLoading ? "Updating..." : "Update Billing Email"}
</Button>
</div>
</form>
</Form>
</CardContent>

View file

@ -31,13 +31,14 @@ export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole:
const isOwner = currentUserRole === OrgRole.OWNER
return (
<Button
className="w-full"
onClick={redirectToCustomerPortal}
disabled={isLoading || !isOwner}
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
>
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
</Button>
)
<div className="flex w-full justify-end">
<Button
onClick={redirectToCustomerPortal}
disabled={isLoading || !isOwner}
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
>
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
</Button>
</div>
)
}

View file

@ -29,6 +29,10 @@ export default async function BillingPage({
return <div>Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.</div>
}
if (!subscription) {
return <div>todo</div>
}
const currentUserRole = await getCurrentUserRole(domain)
if (isServiceError(currentUserRole)) {
return <div>Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.</div>

View file

@ -13,6 +13,7 @@ export default function SettingsLayout({
children: React.ReactNode;
params: { domain: string };
}>) {
const sidebarNavItems = [
{
title: "General",

View file

@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const form = useForm<z.infer<typeof inviteMemberFormSchema>>({
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<typeof formSchema>) => {
const onSubmit = useCallback((data: z.infer<typeof inviteMemberFormSchema>) => {
setIsLoading(true);
createInvites(data.emails.map(e => e.email), domain)
.then((res) => {

View file

@ -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 (
<Link href="mailto:team@sourcebot.dev?subject=Enterprise%20Pricing%20Inquiry">
<UpgradeCard
title="Enterprise"
description="For large organizations with custom needs."
price="Custom"
priceDescription="tailored to your needs"
features={ENTERPRISE_FEATURES}
buttonText="Contact Us"
/>
</Link>
)
}

View file

@ -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 (
<UpgradeCard
isLoading={isLoading}
title="Team"
description="For professional developers and small teams."
price="$10"
priceDescription="per user / month"
features={TEAM_FEATURES}
buttonText={buttonText}
onClick={onClick}
/>
)
}

View file

@ -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 (
<Card
className="transition-all duration-300 hover:border-primary/50 cursor-pointer flex flex-col h-full"
onClick={() => onClick?.()}
>
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-primary">{title}</CardTitle>
<CardDescription className="text-base">{description}</CardDescription>
</CardHeader>
<CardContent className="flex-grow mb-4">
<div className="mb-6">
<p className="text-4xl font-bold text-primary">{price}</p>
<p className="text-sm text-muted-foreground">{priceDescription}</p>
</div>
<ul className="space-y-3">
{features.map((feature, index) => (
<li key={index} className="flex items-center">
<Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => onClick?.()}
disabled={isLoading}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{buttonText}
</Button>
</CardFooter>
</Card>
)
}

View file

@ -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 (
<div className="flex flex-col items-center pt-12 min-h-screen bg-backgroundSecondary relative">
{isTrialing && (
<Link href={`/${domain}`} className="text-sm text-muted-foreground mb-5 absolute top-0 left-0 p-12">
<div className="flex flex-row items-center gap-2">
<ArrowLeftIcon className="w-4 h-4" /> Return to dashboard
</div>
</Link>
)}
<LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
<div className="flex flex-col items-center">
<SourcebotLogo
className="h-16 mb-2"
size="small"
/>
<h1 className="text-3xl font-bold mb-3">
{isTrialing ?
"Upgrade your trial." :
"Your subscription has expired."
}
</h1>
<p className="text-sm text-muted-foreground mb-5">
{isTrialing ?
"Upgrade now to get the most out of Sourcebot." :
"Please upgrade to continue using Sourcebot."
}
</p>
</div>
<OrgSelector
domain={domain}
/>
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mt-12">
<TeamUpgradeCard
buttonText={isTrialing ? "Upgrade Membership" : "Renew Membership"}
/>
<EnterpriseUpgradeCard />
</div>
<Footer />
</div>
)
}

View file

@ -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');

View file

@ -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 (
<div className={className}>
<form
action={async () => {
"use server";
await signOut({
redirectTo: "/login",
});
}}
>
<button
type="submit"
className="flex flex-row items-center gap-2 text-sm text-muted-foreground cursor-pointer"
>
<LogOutIcon className="w-4 h-4" />
Log out
</button>
</form>
</div>
);
}

View file

@ -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;
}

View file

@ -0,0 +1,17 @@
import { cn } from "@/lib/utils"
interface TextSeparatorProps {
className?: string;
text?: string;
}
export const TextSeparator = ({ className, text = "or" }: TextSeparatorProps) => {
return (
<div className={cn("flex items-center w-full gap-4", className)}>
<div className="h-[1px] flex-1 bg-border" />
<span className="text-muted-foreground text-sm">{text}</span>
<div className="h-[1px] flex-1 bg-border" />
</div>
)
}

View file

@ -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 (
<Fragment key={index}>
{child}
{index < elements.length - 1 && <Divider key={`divider-${index}`} />}
{index < elements.length - 1 && <TextSeparator key={`divider-${index}`} />}
</Fragment>
)
})
}
const Divider = ({ className }: { className?: string }) => {
return (
<div className={cn("flex items-center w-full gap-4", className)}>
<div className="h-[1px] flex-1 bg-border" />
<span className="text-muted-foreground text-sm">or</span>
<div className="h-[1px] flex-1 bg-border" />
</div>
)
}

View file

@ -27,7 +27,7 @@ export default async function Login({ searchParams }: LoginProps) {
});
return (
<div className="flex flex-col justify-center items-center h-screen bg-backgroundSecondary">
<div className="flex flex-col items-center p-12 h-screen bg-backgroundSecondary">
<LoginForm
callbackUrl={searchParams.callbackUrl}
error={searchParams.error}

View file

@ -3,7 +3,7 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo";
export default function VerifyPage() {
return (
<div className="flex flex-col items-center justify-center h-screen">
<div className="flex flex-col items-center p-12 h-screen">
<SourcebotLogo
className="mb-2 h-16"
size="small"

View file

@ -1,51 +0,0 @@
import { ErrorPage } from "../components/errorPage";
import { auth } from "@/auth";
import { getUser } from "@/data/user";
import { createOrg, fetchStripeSession } from "../../../actions";
import { isServiceError } from "@/lib/utils";
import { redirect } from 'next/navigation';
interface OnboardCompleteProps {
searchParams?: {
session_id?: string;
org_name?: string;
org_domain?: string;
};
}
export default async function OnboardComplete({ searchParams }: OnboardCompleteProps) {
const sessionId = searchParams?.session_id;
const orgName = searchParams?.org_name;
const orgDomain = searchParams?.org_domain;
const session = await auth();
let user = undefined;
if (!session) {
return null;
}
user = await getUser(session.user.id);
if (!user) {
return null;
}
if (!sessionId || !orgName || !orgDomain) {
console.error("Missing required parameters");
return <ErrorPage />;
}
const stripeSession = await fetchStripeSession(sessionId);
if(stripeSession.payment_status !== "paid") {
console.error("Invalid stripe session");
return <ErrorPage />;
}
const stripeCustomerId = stripeSession.customer as string;
const res = await createOrg(orgName, orgDomain, stripeCustomerId);
if (isServiceError(res)) {
console.error("Failed to create org");
return <ErrorPage />;
}
redirect("/");
}

View file

@ -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 (
<div className="min-h-screen w-full flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardContent className="pt-12 pb-8 px-8 flex flex-col items-center text-center">
<div className="mb-6">
<XCircle className="h-16 w-16 text-red-500" />
</div>
<h1 className="text-2xl font-bold mb-8">Organization Creation Failed</h1>
<p className="text-gray-400 mb-4">
We encountered an error while creating your organization. Please try again.
</p>
<p className="text-gray-400 mb-8">
If the problem persists, please contact us at team@sourcebot.dev
</p>
<Button
onClick={() => router.push("/onboard")}
className="px-6 py-2 h-auto text-base font-medium rounded-xl"
variant="secondary"
>
Try Again
</Button>
</CardContent>
</Card>
</div>
)
}

View file

@ -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 (
<div className="flex flex-col items-center text-center mb-10">
<SourcebotLogo
className="h-16 mb-2"
size="large"
/>
<h1 className="text-3xl font-bold mb-3">
{title}
</h1>
<p className="text-sm text-muted-foreground mb-5">
{description}
</p>
<div className="flex justify-center gap-2">
{steps.map((step, index) => (
<div
key={index}
className={`h-1.5 w-6 rounded-full transition-colors ${step === currentStep ? "bg-gray-400" : "bg-gray-200"}`}
/>
))}
</div>
</div>
)
}

View file

@ -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<typeof onboardingFormSchema>
const defaultValues: Partial<OnboardingFormValues> = {
name: "",
domain: "",
}
interface OrgCreateFormProps {
setOrgCreateData: (data: OnboardingFormValues) => void;
}
export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
const form = useForm<OnboardingFormValues>({ resolver: zodResolver(onboardingFormSchema), defaultValues })
const [errorMessage, setErrorMessage] = useState<string | null>(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<z.infer<typeof onboardingFormSchema>>({
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<typeof onboardingFormSchema>) => {
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<HTMLInputElement>) => {
const name = e.target.value
const domain = name.toLowerCase().replace(/\s+/g, "-")
form.setValue("domain", domain)
}
}
return (
<div className="space-y-6">
<div className="flex justify-center">
<SourcebotLogo
className="h-16"
/>
</div>
<h1 className="text-2xl font-bold">Let&apos;s create your organization</h1>
<Card className="flex flex-col border p-12 space-y-6 bg-background w-96">
<Form {...form}>
<form onSubmit={form.handleSubmit(submitOrgInfoForm)} className="space-y-8">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
@ -76,9 +71,10 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormControl>
<Input
placeholder="Aperture Labs"
{...field}
<Input
placeholder="Aperture Labs"
{...field}
autoFocus
onChange={(e) => {
field.onChange(e)
handleNameChange(e)
@ -105,12 +101,17 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
</FormItem>
)}
/>
{errorMessage && <p className="text-red-500">{errorMessage}</p>}
<div className="flex justify-center">
<Button type="submit">Create</Button>
</div>
<Button
variant="default"
className="w-full"
type="submit"
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Create
</Button>
</form>
</Form>
</div>
</Card>
)
}

View file

@ -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 (
<div>
{trialAck ? (
<div id="checkout">
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ fetchClientSecret: async () => {
const clientSecret = await setupInitialStripeCustomer(orgCreateInfo.name, orgCreateInfo.domain);
if (isServiceError(clientSecret)) {
throw clientSecret;
}
return clientSecret;
} }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
) :
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<div className="flex justify-center mb-4">
<SourcebotLogo
className="h-16"
/>
</div>
<CardTitle className="text-center text-2xl font-bold">7 day free trial</CardTitle>
<CardDescription className="text-center mt-2">Cancel anytime. No credit card required.</CardDescription>
</CardHeader>
<CardContent className="pt-2">
<ul className="space-y-4 mb-6">
{[
"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) => (
<li key={index} className="flex items-center">
<div className="mr-3 flex-shrink-0">
<Check className="h-5 w-5 text-sky-500" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{feature}</p>
</li>
))}
</ul>
<div className="flex justify-center mt-8">
<Button onClick={() => setTrialAck(true)} className="px-8 py-2">
Start trial
</Button>
</div>
</CardContent>
</Card>
}
</div>
)
}

View file

@ -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<OnboardingFormValues | undefined>(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 (
<div className="flex flex-col justify-center items-center h-screen">
{orgCreateInfo ? (
<TrialCard orgCreateInfo={ orgCreateInfo } />
) : (
<div className="flex flex-col items-center border p-16 rounded-lg gap-6">
<OrgCreateForm setOrgCreateData={setOrgInfo} />
</div>
)}
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary fade-in-20 relative">
<OnboardHeader
title="Setup your organization"
description="Create a organization for your team to search and share code across your repositories."
step={OnboardingSteps.CreateOrg}
/>
<OrgCreateForm />
<LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
</div>
);
}

View file

@ -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 (
<ErrorLayout title="This organization's subscription has expired. Please renew the subscription and try again." />
)
}
}
return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto mb-4">

View file

@ -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",
]

View file

@ -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',
}