Single tenancy & auth modes (#233)

This commit is contained in:
Brendan Kellam 2025-03-20 13:39:28 -07:00 committed by GitHub
parent 583df1dd77
commit 4ecd7009cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 690 additions and 425 deletions

View file

@ -81,3 +81,4 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection
# CONFIG_MAX_REPOS_NO_TOKEN=
# SOURCEBOT_ROOT_DOMAIN=
# NODE_ENV=
# SOURCEBOT_TENANCY_MODE=mutli

View file

@ -2,7 +2,7 @@
import Ajv from "ajv";
import { auth } from "./auth";
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists } from "@/lib/serviceError";
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists, stripeClientNotInitialized } from "@/lib/serviceError";
import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
@ -16,7 +16,6 @@ import { decrypt, encrypt } from "@sourcebot/crypto"
import { getConnection } from "./data/connection";
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { cookies, headers } from "next/headers"
import { getUser } from "@/data/user";
import { Session } from "next-auth";
import { env } from "@/env.mjs";
import Stripe from "stripe";
@ -24,8 +23,8 @@ import { render } from "@react-email/components";
import InviteUserEmail from "./emails/inviteUserEmail";
import { createTransport } from "nodemailer";
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { RepositoryQuery } from "./lib/types";
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants";
import { TenancyMode } from "./lib/types";
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants";
import { stripeClient } from "./lib/stripe";
import { IS_BILLING_ENABLED } from "./lib/stripe";
@ -33,9 +32,27 @@ const ajv = new Ajv({
validateFormats: false,
});
export const withAuth = async <T>(fn: (session: Session) => Promise<T>) => {
export const withAuth = async <T>(fn: (session: Session) => Promise<T>, allowSingleTenantUnauthedAccess: boolean = false) => {
const session = await auth();
if (!session) {
if (
env.SOURCEBOT_TENANCY_MODE === 'single' &&
env.SOURCEBOT_AUTH_ENABLED === 'false' &&
allowSingleTenantUnauthedAccess === true
) {
// To allow for unauthed acccess in single-tenant mode, we can
// create a fake session with the default user. This user has membership
// in the default org.
// @see: initialize.ts
return fn({
user: {
id: SINGLE_TENANT_USER_ID,
email: SINGLE_TENANT_USER_EMAIL,
},
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
});
}
return notAuthenticated();
}
return fn(session);
@ -89,12 +106,19 @@ export const withOrgMembership = async <T>(session: Session, domain: string, fn:
});
}
export const isAuthed = async () => {
const session = await auth();
return session != null;
export const withTenancyModeEnforcement = async<T>(mode: TenancyMode, fn: () => Promise<T>) => {
if (env.SOURCEBOT_TENANCY_MODE !== mode) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.ACTION_DISALLOWED_IN_TENANCY_MODE,
message: "This action is not allowed in the current tenancy mode.",
} satisfies ServiceError;
}
return fn();
}
export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> =>
withTenancyModeEnforcement('multi', () =>
withAuth(async (session) => {
const org = await prisma.org.create({
data: {
@ -116,7 +140,7 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number }
return {
id: org.id,
}
});
}));
export const updateOrgName = async (name: string, domain: string) =>
withAuth((session) =>
@ -139,9 +163,10 @@ export const updateOrgName = async (name: string, domain: string) =>
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
)
);
export const updateOrgDomain = async (newDomain: string, existingDomain: string) =>
withTenancyModeEnforcement('multi', () =>
withAuth((session) =>
withOrgMembership(session, existingDomain, async ({ orgId }) => {
const { success } = await orgDomainSchema.safeParseAsync(newDomain);
@ -161,8 +186,8 @@ export const updateOrgDomain = async (newDomain: string, existingDomain: string)
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER),
)
}, /* minRequiredRole = */ OrgRole.OWNER)
));
export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
@ -224,7 +249,6 @@ export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: stri
key: secret.key,
createdAt: secret.createdAt,
}));
}));
export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
@ -275,8 +299,7 @@ export const checkIfSecretExists = async (key: string, domain: string): Promise<
});
return !!secret;
})
);
}));
export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
@ -360,9 +383,9 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
numLinkedRepos: connection.repos.length,
}
})
)
);
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}): Promise<RepositoryQuery[] | ServiceError> =>
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const repos = await prisma.repo.findMany({
@ -401,8 +424,8 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt
indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus,
}));
})
);
}
), /* allowSingleTenantUnauthedAccess = */ true);
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
withAuth((session) =>
@ -424,7 +447,8 @@ export const createConnection = async (name: string, type: string, connectionCon
return {
id: connection.id,
}
}));
})
);
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
@ -695,8 +719,40 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
}, /* minRequiredRole = */ OrgRole.OWNER)
);
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
export const getMe = async () =>
withAuth(async (session) => {
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
include: {
orgs: {
include: {
org: true,
}
},
}
});
if (!user) {
return notFound();
}
return {
id: user.id,
email: user.email,
name: user.name,
memberships: user.orgs.map((org) => ({
id: org.orgId,
role: org.role,
domain: org.org.domain,
name: org.org.name,
}))
}
});
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async () => {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
@ -710,9 +766,9 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
return notFound();
}
const user = await getUser(session.user.id);
if (!user) {
return notFound();
const user = await getMe();
if (isServiceError(user)) {
return user;
}
// Check if the user is the recipient of the invite
@ -765,10 +821,10 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
});
export const getInviteInfo = async (inviteId: string) =>
withAuth(async (session) => {
const user = await getUser(session.user.id);
if (!user) {
return notFound();
withAuth(async () => {
const user = await getMe();
if (isServiceError(user)) {
return user;
}
const invite = await prisma.invite.findUnique({
@ -880,17 +936,13 @@ export const createOnboardingSubscription = async (domain: string) =>
return notFound();
}
const user = await getUser(session.user.id);
if (!user) {
return notFound();
const user = await getMe();
if (isServiceError(user)) {
return user;
}
if (!stripeClient) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
return stripeClientNotInitialized();
}
const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({
@ -992,11 +1044,7 @@ export const createStripeCheckoutSession = async (domain: string) =>
}
if (!stripeClient) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
return stripeClientNotInitialized();
}
const orgMembers = await prisma.userToOrg.findMany({
@ -1042,7 +1090,7 @@ export const createStripeCheckoutSession = async (domain: string) =>
url: stripeSession.url,
}
})
)
);
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
withAuth((session) =>
@ -1058,11 +1106,7 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise<stri
}
if (!stripeClient) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
return stripeClientNotInitialized();
}
const origin = (await headers()).get('origin')
@ -1096,11 +1140,7 @@ export const getSubscriptionBillingEmail = async (domain: string): Promise<strin
}
if (!stripeClient) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
return stripeClientNotInitialized();
}
const customer = await stripeClient.customers.retrieve(org.stripeCustomerId);
@ -1125,11 +1165,7 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s
}
if (!stripeClient) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
return stripeClientNotInitialized();
}
await stripeClient.customers.update(org.stripeCustomerId, {
@ -1351,11 +1387,7 @@ const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.Transactio
}
if (!stripeClient) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
return stripeClientNotInitialized();
}
const subscriptions = await stripeClient.subscriptions.list({
@ -1401,6 +1433,15 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
} satisfies ServiceError;
}
const isValidConfig = ajv.validate(schema, parsedConfig);
if (!isValidConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
} satisfies ServiceError;
}
const { numRepos, hasToken } = (() => {
switch (connectionType) {
case "github": {
@ -1447,15 +1488,6 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
} satisfies ServiceError;
}
const isValidConfig = ajv.validate(schema, parsedConfig);
if (!isValidConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
} satisfies ServiceError;
}
return parsedConfig;
}

View file

@ -4,11 +4,11 @@ import { Separator } from '@/components/ui/separator';
import { getFileSource, listRepositories } from '@/lib/server/searchService';
import { base64Decode, isServiceError } from "@/lib/utils";
import { CodePreview } from "./codePreview";
import { PageNotFound } from "@/app/[domain]/components/pageNotFound";
import { ErrorCode } from "@/lib/errorCodes";
import { LuFileX2, LuBookX } from "react-icons/lu";
import { getOrgFromDomain } from "@/data/org";
import { notFound } from "next/navigation";
import { ServiceErrorException } from "@/lib/serviceError";
interface BrowsePageProps {
params: {
path: string[];
@ -22,7 +22,7 @@ export default async function BrowsePage({
const rawPath = decodeURIComponent(params.path.join('/'));
const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//);
if (sentinalIndex === -1) {
return <PageNotFound />;
notFound();
}
const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@');
@ -48,19 +48,14 @@ export default async function BrowsePage({
const org = await getOrgFromDomain(params.domain);
if (!org) {
return <PageNotFound />
notFound();
}
// @todo (bkellam) : We should probably have a endpoint to fetch repository metadata
// given it's name or id.
const reposResponse = await listRepositories(org.id);
if (isServiceError(reposResponse)) {
// @todo : proper error handling
return (
<>
Error: {reposResponse.message}
</>
)
throw new ServiceErrorException(reposResponse);
}
const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName);
@ -145,12 +140,7 @@ const CodePreviewWrapper = async ({
)
}
// @todo : proper error handling
return (
<>
Error: {fileSourceResponse.message}
</>
)
throw new ServiceErrorException(fileSourceResponse);
}
return (

View file

@ -63,7 +63,7 @@ export const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHo
const response = await createSecret(data.key, data.value, domain);
if (isServiceError(response)) {
toast({
description: `❌ Failed to create secret`
description: `❌ Failed to create secret. Reason: ${response.message}`
});
captureEvent('wa_secret_combobox_import_secret_fail', {
type: codeHostType,

View file

@ -13,6 +13,7 @@ import { ProgressNavIndicator } from "./progressNavIndicator";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { TrialNavIndicator } from "./trialNavIndicator";
import { IS_BILLING_ENABLED } from "@/lib/stripe";
import { env } from "@/env.mjs";
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
@ -39,10 +40,14 @@ export const NavigationMenu = async ({
/>
</Link>
{env.SOURCEBOT_TENANCY_MODE === 'multi' && (
<>
<OrgSelector
domain={domain}
/>
<Separator orientation="vertical" className="h-6 mx-2" />
</>
)}
<NavigationMenuBase>
<NavigationMenuList>
@ -60,6 +65,7 @@ export const NavigationMenu = async ({
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
{env.SOURCEBOT_AUTH_ENABLED === 'true' && (
<NavigationMenuItem>
<Link href={`/${domain}/connections`} legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
@ -67,6 +73,8 @@ export const NavigationMenu = async ({
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
)}
{env.SOURCEBOT_AUTH_ENABLED === 'true' && (
<NavigationMenuItem>
<Link href={`/${domain}/settings`} legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
@ -74,6 +82,7 @@ export const NavigationMenu = async ({
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
)}
</NavigationMenuList>
</NavigationMenuBase>
</div>

View file

@ -1,7 +1,7 @@
import { auth } from "@/auth";
import { getUserOrgs } from "../../../../data/user";
import { OrgSelectorDropdown } from "./orgSelectorDropdown";
import { prisma } from "@/prisma";
import { getMe } from "@/actions";
import { isServiceError } from "@/lib/utils";
interface OrgSelectorProps {
domain: string;
@ -10,12 +10,11 @@ interface OrgSelectorProps {
export const OrgSelector = async ({
domain,
}: OrgSelectorProps) => {
const session = await auth();
if (!session) {
const user = await getMe();
if (isServiceError(user)) {
return null;
}
const orgs = await getUserOrgs(session.user.id);
const activeOrg = await prisma.org.findUnique({
where: {
domain,
@ -28,10 +27,10 @@ export const OrgSelector = async ({
return (
<OrgSelectorDropdown
orgs={orgs.map((org) => ({
name: org.name,
id: org.id,
domain: org.domain,
orgs={user.memberships.map(({ name, domain, id }) => ({
name,
domain,
id,
}))}
activeOrgId={activeOrg.id}
/>

View file

@ -15,7 +15,6 @@ import { ConfigSetting } from "./components/configSetting"
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"
import { DisplayNameSetting } from "./components/displayNameSetting"
import { RepoList } from "./components/repoList"
import { auth } from "@/auth"
import { getConnectionByDomain } from "@/data/connection"
import { Overview } from "./components/overview"
@ -30,11 +29,6 @@ interface ConnectionManagementPageProps {
}
export default async function ConnectionManagementPage({ params, searchParams }: ConnectionManagementPageProps) {
const session = await auth();
if (!session) {
return null;
}
const connection = await getConnectionByDomain(Number(params.id), params.domain);
if (!connection) {
return <NotFound className="flex w-full h-full items-center justify-center" message="Connection not found" />
@ -42,7 +36,6 @@ export default async function ConnectionManagementPage({ params, searchParams }:
const currentTab = searchParams.tab || "overview";
return (
<Tabs value={currentTab} className="w-full">
<Header className="mb-6" withTopMargin={false}>

View file

@ -1,14 +1,14 @@
import { ConnectionList } from "./components/connectionList";
import { Header } from "../components/header";
import { NewConnectionCard } from "./components/newConnectionCard";
import NotFoundPage from "@/app/not-found";
import { getConnections } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { ServiceErrorException } from "@/lib/serviceError";
export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) {
const connections = await getConnections(domain);
if (isServiceError(connections)) {
return <NotFoundPage />;
throw new ServiceErrorException(connections);
}
return (

View file

@ -13,7 +13,8 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/co
import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide";
import { SyntaxGuideProvider } from "./components/syntaxGuideProvider";
import { IS_BILLING_ENABLED } from "@/lib/stripe";
import { env } from "@/env.mjs";
import { notFound, redirect } from "next/navigation";
interface LayoutProps {
children: React.ReactNode,
params: { domain: string }
@ -26,16 +27,15 @@ export default async function Layout({
const org = await getOrgFromDomain(domain);
if (!org) {
return <PageNotFound />
return notFound();
}
if (env.SOURCEBOT_AUTH_ENABLED === 'true') {
const session = await auth();
if (!session) {
return <PageNotFound />
redirect('/login');
}
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
@ -46,7 +46,8 @@ export default async function Layout({
});
if (!membership) {
return <PageNotFound />
return notFound();
}
}
if (!org.isOnboarded) {

View file

@ -43,48 +43,48 @@ export default async function Home({ params: { domain } }: { params: { domain: s
title="Search in files or paths"
>
<QueryExample>
<Query query="test todo">test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="test or todo">test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query={`"exit boot"`}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="TODO case:yes">TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
</QueryExample>
</HowToSection>
<HowToSection
title="Filter results"
>
<QueryExample>
<Query query="file:README setup"><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="repo:torvalds/linux test"><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="lang:typescript"><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="rev:HEAD"><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
</QueryExample>
</HowToSection>
<HowToSection
title="Advanced"
>
<QueryExample>
<Query query="file:\.py$"><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="sym:main"><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="todo -lang:c">todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="content:README"><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
</QueryExample>
</HowToSection>
</div>
@ -130,10 +130,10 @@ const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
)
}
const Query = ({ query, children }: { query: string, children: React.ReactNode }) => {
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
return (
<Link
href={`/search?query=${query}`}
href={`/${domain}/search?query=${query}`}
className="cursor-pointer hover:underline"
>
{children}

View file

@ -93,13 +93,13 @@ const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => {
)
}
export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): ColumnDef<RepositoryColumnInfo>[] => [
{
accessorKey: "name",
header: () => (
<div className="flex items-center w-[400px]">
<span>Repository</span>
<AddRepoButton />
{isAddNewRepoButtonVisible && <AddRepoButton />}
</div>
),
cell: ({ row }) => {

View file

@ -2,6 +2,8 @@ import { RepositoryTable } from "./repositoryTable";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "../components/pageNotFound";
import { Header } from "../components/header";
import { env } from "@/env.mjs";
export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) {
const org = await getOrgFromDomain(domain);
if (!org) {
@ -15,7 +17,9 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
</Header>
<div className="flex flex-col items-center">
<div className="w-full">
<RepositoryTable />
<RepositoryTable
isAddNewRepoButtonVisible={env.SOURCEBOT_AUTH_ENABLED === 'true'}
/>
</div>
</div>
</div>

View file

@ -11,7 +11,11 @@ import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { env } from "@/env.mjs";
export const RepositoryTable = () => {
interface RepositoryTableProps {
isAddNewRepoButtonVisible: boolean;
}
export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTableProps) => {
const domain = useDomain();
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
@ -48,7 +52,7 @@ export const RepositoryTable = () => {
const tableColumns = useMemo(() => {
if (reposLoading) {
return columns(domain).map((column) => {
return columns(domain, isAddNewRepoButtonVisible).map((column) => {
if ('accessorKey' in column && column.accessorKey === "name") {
return {
...column,
@ -72,7 +76,7 @@ export const RepositoryTable = () => {
})
}
return columns(domain);
return columns(domain, isAddNewRepoButtonVisible);
}, [reposLoading, domain]);

View file

@ -1,11 +1,11 @@
import { auth } from "@/auth";
import { ChangeOrgNameCard } from "./components/changeOrgNameCard";
import { isServiceError } from "@/lib/utils";
import { getCurrentUserRole } from "@/actions";
import { getOrgFromDomain } from "@/data/org";
import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard";
import { env } from "@/env.mjs";
import { ServiceErrorException } from "@/lib/serviceError";
import { ErrorCode } from "@/lib/errorCodes";
interface GeneralSettingsPageProps {
params: {
domain: string;
@ -13,19 +13,18 @@ interface GeneralSettingsPageProps {
}
export default async function GeneralSettingsPage({ params: { domain } }: GeneralSettingsPageProps) {
const session = await auth();
if (!session) {
return null;
}
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>
throw new ServiceErrorException(currentUserRole);
}
const org = await getOrgFromDomain(domain)
if (!org) {
return <div>Failed to fetch organization. Please contact us at team@sourcebot.dev if this issue persists.</div>
throw new ServiceErrorException({
message: "Failed to fetch organization.",
statusCode: 500,
errorCode: ErrorCode.NOT_FOUND,
});
}
return (

View file

@ -7,7 +7,7 @@ import { isServiceError } from "@/lib/utils"
import { ChangeBillingEmailCard } from "./changeBillingEmailCard"
import { notFound } from "next/navigation"
import { IS_BILLING_ENABLED } from "@/lib/stripe"
import { ServiceErrorException } from "@/lib/serviceError"
export const metadata: Metadata = {
title: "Billing | Settings",
description: "Manage your subscription and billing information",
@ -29,21 +29,21 @@ export default async function BillingPage({
const subscription = await getSubscriptionData(domain)
if (isServiceError(subscription)) {
return <div>Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.</div>
throw new ServiceErrorException(subscription);
}
if (!subscription) {
return <div>todo</div>
throw new Error("Subscription not found");
}
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>
throw new ServiceErrorException(currentUserRole);
}
const billingEmail = await getSubscriptionBillingEmail(domain);
if (isServiceError(billingEmail)) {
return <div>Failed to fetch billing email. Please contact us at team@sourcebot.dev if this issue persists.</div>
throw new ServiceErrorException(billingEmail);
}
return (

View file

@ -1,15 +1,14 @@
import { MembersList } from "./components/membersList";
import { getOrgMembers } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { auth } from "@/auth";
import { getUser, getUserRoleInOrg } from "@/data/user";
import { getOrgFromDomain } from "@/data/org";
import { InviteMemberCard } from "./components/inviteMemberCard";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TabSwitcher } from "@/components/ui/tab-switcher";
import { InvitesList } from "./components/invitesList";
import { getOrgInvites } from "@/actions";
import { getOrgInvites, getMe } from "@/actions";
import { IS_BILLING_ENABLED } from "@/lib/stripe";
import { ServiceErrorException } from "@/lib/serviceError";
interface MembersSettingsPageProps {
params: {
domain: string
@ -20,34 +19,29 @@ interface MembersSettingsPageProps {
}
export default async function MembersSettingsPage({ params: { domain }, searchParams: { tab } }: MembersSettingsPageProps) {
const session = await auth();
if (!session) {
return null;
const org = await getOrgFromDomain(domain);
if (!org) {
throw new Error("Organization not found");
}
const me = await getMe();
if (isServiceError(me)) {
throw new ServiceErrorException(me);
}
const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
if (!userRoleInOrg) {
throw new Error("User role not found");
}
const members = await getOrgMembers(domain);
const org = await getOrgFromDomain(domain);
if (!org) {
return null;
}
const user = await getUser(session.user.id);
if (!user) {
return null;
}
const userRoleInOrg = await getUserRoleInOrg(user.id, org.id);
if (!userRoleInOrg) {
return null;
}
if (isServiceError(members)) {
return null;
throw new ServiceErrorException(members);
}
const invites = await getOrgInvites(domain);
if (isServiceError(invites)) {
return null;
throw new ServiceErrorException(invites);
}
const currentTab = tab || "members";
@ -78,7 +72,7 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
<TabsContent value="members">
<MembersList
members={members}
currentUserId={session.user.id}
currentUserId={me.id}
currentUserRole={userRoleInOrg}
orgName={org.name}
/>

View file

@ -2,6 +2,8 @@ import { getSecrets } from "@/actions";
import { SecretsList } from "./components/secretsList";
import { isServiceError } from "@/lib/utils";
import { ImportSecretCard } from "./components/importSecretCard";
import { ServiceErrorException } from "@/lib/serviceError";
interface SecretsPageProps {
params: {
domain: string;
@ -11,7 +13,7 @@ interface SecretsPageProps {
export default async function SecretsPage({ params: { domain } }: SecretsPageProps) {
const secrets = await getSecrets(domain);
if (isServiceError(secrets)) {
return null;
throw new ServiceErrorException(secrets);
}
return (

View file

@ -9,8 +9,13 @@ import { isServiceError } from "@/lib/utils";
import Link from "next/link";
import { ArrowLeftIcon } from "@radix-ui/react-icons";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { env } from "@/env.mjs";
import { IS_BILLING_ENABLED } from "@/lib/stripe";
export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) {
if (!IS_BILLING_ENABLED) {
redirect(`/${domain}`);
}
const subscription = await fetchSubscription(domain);
if (!subscription) {
@ -52,9 +57,11 @@ export default async function Upgrade({ params: { domain } }: { params: { domain
</p>
</div>
{env.SOURCEBOT_TENANCY_MODE === 'multi' && (
<OrgSelector
domain={domain}
/>
)}
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mt-12">
<TeamUpgradeCard

View file

@ -1,74 +0,0 @@
import { ErrorCode } from "@/lib/errorCodes";
import { verifyCredentialsRequestSchema } from "@/lib/schemas";
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { prisma } from "@/prisma";
import { User as NextAuthUser } from "next-auth";
import bcrypt from 'bcrypt';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const body = await request.json();
const parsed = await verifyCredentialsRequestSchema.safeParseAsync(body);
if (!parsed.success) {
return serviceErrorResponse(
schemaValidationError(parsed.error)
)
}
const { email, password } = parsed.data;
const user = await getOrCreateUser(email, password);
if (!user) {
return serviceErrorResponse(
{
statusCode: 401,
errorCode: ErrorCode.INVALID_CREDENTIALS,
message: 'Invalid email or password',
}
)
}
return Response.json(user);
}
async function getOrCreateUser(email: string, password: string): Promise<NextAuthUser | null> {
const user = await prisma.user.findUnique({
where: { email }
});
// The user doesn't exist, so create a new one.
if (!user) {
const hashedPassword = bcrypt.hashSync(password, 10);
const newUser = await prisma.user.create({
data: {
email,
hashedPassword,
}
});
return {
id: newUser.id,
email: newUser.email,
}
// Otherwise, the user exists, so verify the password.
} else {
if (!user.hashedPassword) {
return null;
}
if (!bcrypt.compareSync(password, user.hashedPassword)) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name ?? undefined,
image: user.image ?? undefined,
};
}
}

View file

@ -22,5 +22,5 @@ const getRepos = (domain: string) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const response = await listRepositories(orgId);
return response;
})
);
}
), /* allowSingleTenantUnauthedAccess */ true);

View file

@ -30,4 +30,5 @@ const postSearch = (request: SearchRequest, domain: string) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const response = await search(request, orgId);
return response;
}))
}
), /* allowSingleTenantUnauthedAccess */ true);

View file

@ -32,4 +32,5 @@ const postSource = (request: FileSourceRequest, domain: string) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const response = await getFileSource(request, orgId);
return response;
}));
}
), /* allowSingleTenantUnauthedAccess */ true);

View file

@ -0,0 +1,148 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect, useMemo } from 'react'
import { useState } from "react"
import { Copy, CheckCircle2, TriangleAlert } from "lucide-react"
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { serviceErrorSchema } from '@/lib/serviceError';
import { SourcebotLogo } from './components/sourcebotLogo';
export default function Error({ error, reset }: { error: Error & { digest?: string }, reset: () => void }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
const { message, errorCode, statusCode } = useMemo(() => {
try {
const body = JSON.parse(error.message);
const { success, data: serviceError } = serviceErrorSchema.safeParse(body);
if (success) {
return {
message: serviceError.message,
errorCode: serviceError.errorCode,
statusCode: serviceError.statusCode,
}
}
} catch { }
return {
message: error.message,
}
}, [error]);
return (
<div className="flex flex-col min-h-screen justify-center items-center bg-backgroundSecondary">
<SourcebotLogo
className="mb-4"
size='large'
/>
<ErrorCard
message={message}
errorCode={errorCode}
statusCode={statusCode}
onReloadButtonClicked={reset}
/>
</div>
)
}
interface ErrorCardProps {
message: string
errorCode?: string | number
statusCode?: string | number
onReloadButtonClicked: () => void
}
function ErrorCard({ message, errorCode, statusCode, onReloadButtonClicked }: ErrorCardProps) {
const [copied, setCopied] = useState<string | null>(null)
const copyToClipboard = (text: string, field: string) => {
navigator.clipboard.writeText(text)
setCopied(field)
setTimeout(() => setCopied(null), 2000)
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader className="space-y-1 flex">
<CardTitle className="text-2xl font-bold flex items-center gap-2 text-destructive">
<TriangleAlert className="h-5 w-5 mt-0.5" />
Unexpected Error
</CardTitle>
<CardDescription className="text-sm">
An unexpected error occurred. Please reload the page and try again. If the issue persists, <Link href={`mailto:team@sourcebot.dev?subject=Sourcebot%20Error%20Report${errorCode ? `%20|%20Code:%20${errorCode}` : ''}`} className='underline'>please contact us</Link>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<ErrorField
label="Error Message"
value={message}
onCopy={() => copyToClipboard(message, "message")}
copied={copied === "message"}
/>
{errorCode && (
<ErrorField
label="Error Code"
value={errorCode}
onCopy={() => copyToClipboard(errorCode.toString(), "errorCode")}
copied={copied === "errorCode"}
/>
)}
{statusCode && (
<ErrorField
label="Status Code"
value={statusCode}
onCopy={() => copyToClipboard(statusCode.toString(), "statusCode")}
copied={copied === "statusCode"}
/>
)}
</div>
<Button
onClick={onReloadButtonClicked}
variant='outline'
className='w-full'
>
Reload Page
</Button>
</CardContent>
</Card>
)
}
interface ErrorFieldProps {
label: string
value: string | number
onCopy: () => void
copied: boolean
}
function ErrorField({ label, value, onCopy, copied }: ErrorFieldProps) {
return (
<div className="space-y-2">
<div className="text-sm font-medium">{label}</div>
<div className="flex items-center gap-2">
<div className="bg-muted p-2 rounded text-sm flex-1 break-words">{value}</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={onCopy}
aria-label={`Copy ${label.toLowerCase()}`}
>
{copied ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
)
}

View file

@ -1,5 +1,5 @@
import 'next-auth/jwt';
import NextAuth, { DefaultSession } from "next-auth"
import NextAuth, { DefaultSession, User as AuthJsUser } from "next-auth"
import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google"
import Credentials from "next-auth/providers/credentials"
@ -7,13 +7,15 @@ import EmailProvider from "next-auth/providers/nodemailer";
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/prisma";
import { env } from "@/env.mjs";
import { User } from '@sourcebot/db';
import { OrgRole, User } from '@sourcebot/db';
import 'next-auth/jwt';
import type { Provider } from "next-auth/providers";
import { verifyCredentialsRequestSchema, verifyCredentialsResponseSchema } from './lib/schemas';
import { verifyCredentialsRequestSchema } from './lib/schemas';
import { createTransport } from 'nodemailer';
import { render } from '@react-email/render';
import MagicLinkEmail from './emails/magicLinkEmail';
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
import bcrypt from 'bcrypt';
export const runtime = 'nodejs';
@ -90,23 +92,44 @@ export const getProviders = () => {
}
const { email, password } = body.data;
// authorize runs in the edge runtime (where we cannot make DB calls / access environment variables),
// so we need to make a request to the server to verify the credentials.
const response = await fetch(new URL('/api/auth/verifyCredentials', env.AUTH_URL), {
method: 'POST',
body: JSON.stringify({ email, password }),
const user = await prisma.user.findUnique({
where: { email }
});
if (!response.ok) {
// The user doesn't exist, so create a new one.
if (!user) {
const hashedPassword = bcrypt.hashSync(password, 10);
const newUser = await prisma.user.create({
data: {
email,
hashedPassword,
}
});
const authJsUser: AuthJsUser = {
id: newUser.id,
email: newUser.email,
}
onCreateUser({ user: authJsUser });
return authJsUser;
// Otherwise, the user exists, so verify the password.
} else {
if (!user.hashedPassword) {
return null;
}
if (!bcrypt.compareSync(password, user.hashedPassword)) {
return null;
}
const user = verifyCredentialsResponseSchema.parse(await response.json());
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
name: user.name ?? undefined,
image: user.image ?? undefined,
};
}
}
}));
@ -115,6 +138,47 @@ export const getProviders = () => {
return providers;
}
const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
// In single-tenant mode w/ auth, we assign the first user to sign
// up as the owner of the default org.
if (
env.SOURCEBOT_TENANCY_MODE === 'single' &&
env.SOURCEBOT_AUTH_ENABLED === 'true'
) {
await prisma.$transaction(async (tx) => {
const defaultOrg = await tx.org.findUnique({
where: {
id: SINGLE_TENANT_ORG_ID,
},
include: {
members: true,
}
});
// Only the first user to sign up will be an owner of the default org.
if (defaultOrg?.members.length === 0) {
await tx.org.update({
where: {
id: SINGLE_TENANT_ORG_ID,
},
data: {
members: {
create: {
role: OrgRole.OWNER,
user: {
connect: {
id: user.id,
}
}
}
}
}
});
}
});
}
}
const useSecureCookies = env.AUTH_URL?.startsWith("https://") ?? false;
const hostName = env.AUTH_URL ? new URL(env.AUTH_URL).hostname : "localhost";
@ -125,6 +189,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
strategy: "jwt",
},
trustHost: true,
events: {
createUser: onCreateUser,
},
callbacks: {
async jwt({ token, user: _user }) {
const user = _user as User | undefined;

View file

@ -1,39 +0,0 @@
import 'server-only';
import { prisma } from "@/prisma";
export const getUser = async (userId: string) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
return user;
}
export const getUserOrgs = async (userId: string) => {
const orgs = await prisma.org.findMany({
where: {
members: {
some: {
userId: userId,
},
},
},
});
return orgs;
}
export const getUserRoleInOrg = async (userId: string, orgId: number) => {
const userToOrg = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId,
orgId,
}
},
});
return userToOrg?.role;
}

View file

@ -3,6 +3,7 @@ import { z } from "zod";
// Booleans are specified as 'true' or 'false' strings.
const booleanSchema = z.enum(["true", "false"]);
export const tenancyModeSchema = z.enum(["multi", "single"]);
// Numbers are treated as strings in .env files.
// coerce helps us convert them to numbers.
@ -36,11 +37,14 @@ export const env = createEnv({
STRIPE_ENABLE_TEST_CLOCKS: booleanSchema.default('false'),
// Misc
CONFIG_MAX_REPOS_NO_TOKEN: numberSchema.default(500),
CONFIG_MAX_REPOS_NO_TOKEN: numberSchema.default(Number.MAX_SAFE_INTEGER),
SOURCEBOT_ROOT_DOMAIN: z.string().default("localhost:3000"),
NODE_ENV: z.enum(["development", "test", "production"]),
SOURCEBOT_TELEMETRY_DISABLED: booleanSchema.default('false'),
DATABASE_URL: z.string().url(),
SOURCEBOT_TENANCY_MODE: tenancyModeSchema.default("single"),
SOURCEBOT_AUTH_ENABLED: booleanSchema.default('true'),
},
// @NOTE: Make sure you destructure all client variables in the
// `experimental__runtimeEnv` block below.

View file

@ -0,0 +1,57 @@
import { OrgRole } from '@sourcebot/db';
import { env } from './env.mjs';
import { prisma } from "@/prisma";
import { SINGLE_TENANT_USER_ID, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_NAME, SINGLE_TENANT_USER_EMAIL } from './lib/constants';
if (env.SOURCEBOT_AUTH_ENABLED === 'false' && env.SOURCEBOT_TENANCY_MODE === 'multi') {
throw new Error('SOURCEBOT_AUTH_ENABLED must be true when SOURCEBOT_TENANCY_MODE is multi');
}
const initSingleTenancy = async () => {
await prisma.org.upsert({
where: {
id: SINGLE_TENANT_ORG_ID,
},
update: {},
create: {
name: SINGLE_TENANT_ORG_NAME,
domain: SINGLE_TENANT_ORG_DOMAIN,
id: SINGLE_TENANT_ORG_ID,
isOnboarded: env.SOURCEBOT_AUTH_ENABLED === 'false',
}
});
if (env.SOURCEBOT_AUTH_ENABLED === 'false') {
// Default user for single tenancy unauthed access
await prisma.user.upsert({
where: {
id: SINGLE_TENANT_USER_ID,
},
update: {},
create: {
id: SINGLE_TENANT_USER_ID,
email: SINGLE_TENANT_USER_EMAIL,
},
});
await prisma.org.update({
where: {
id: SINGLE_TENANT_ORG_ID,
},
data: {
members: {
create: {
role: OrgRole.MEMBER,
user: {
connect: { id: SINGLE_TENANT_USER_ID }
}
}
}
}
});
}
}
if (env.SOURCEBOT_TENANCY_MODE === 'single') {
await initSingleTenancy();
}

View file

@ -8,6 +8,10 @@ export async function register() {
if (process.env.NEXT_RUNTIME === 'edge') {
await import('../sentry.edge.config');
}
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import ('./initialize');
}
}
export const onRequestError = Sentry.captureRequestError;

View file

@ -23,3 +23,9 @@ export const TEAM_FEATURES = [
]
export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed';
export const SINGLE_TENANT_USER_ID = '1';
export const SINGLE_TENANT_USER_EMAIL = 'default@sourcebot.dev';
export const SINGLE_TENANT_ORG_ID = 1;
export const SINGLE_TENANT_ORG_DOMAIN = '~';
export const SINGLE_TENANT_ORG_NAME = 'default';

View file

@ -20,4 +20,5 @@ export enum ErrorCode {
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',
STRIPE_CLIENT_NOT_INITIALIZED = 'STRIPE_CLIENT_NOT_INITIALIZED',
ACTION_DISALLOWED_IN_TENANCY_MODE = 'ACTION_DISALLOWED_IN_TENANCY_MODE',
}

View file

@ -183,14 +183,6 @@ export const verifyCredentialsRequestSchema = z.object({
password: z.string().min(8),
});
export const verifyCredentialsResponseSchema = z.object({
id: z.string().optional(),
name: z.string().optional(),
email: z.string().optional(),
image: z.string().optional(),
});
export const orgNameSchema = z.string().min(2, { message: "Organization name must be at least 3 characters long." });
export const orgDomainSchema = z.string()

View file

@ -1,11 +1,22 @@
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "./errorCodes";
import { ZodError } from "zod";
import { z, ZodError } from "zod";
export interface ServiceError {
statusCode: StatusCodes;
errorCode: ErrorCode;
message: string;
export const serviceErrorSchema = z.object({
statusCode: z.number(),
errorCode: z.string(),
message: z.string(),
});
export type ServiceError = z.infer<typeof serviceErrorSchema>;
/**
* Useful for throwing errors and handling them in error boundaries.
*/
export class ServiceErrorException extends Error {
constructor(public readonly serviceError: ServiceError) {
super(JSON.stringify(serviceError));
}
}
export const serviceErrorResponse = ({ statusCode, errorCode, message }: ServiceError) => {
@ -108,3 +119,11 @@ export const secretAlreadyExists = (): ServiceError => {
message: "Secret already exists",
}
}
export const stripeClientNotInitialized = (): ServiceError => {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
}
}

View file

@ -1,5 +1,6 @@
import { z } from "zod";
import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, repositoryQuerySchema, searchRequestSchema, searchResponseSchema, symbolSchema, getVersionResponseSchema } from "./schemas";
import { tenancyModeSchema } from "@/env.mjs";
export type KeymapType = "default" | "vim";
@ -26,3 +27,5 @@ export enum SearchQueryParams {
query = "query",
maxMatchDisplayCount = "maxMatchDisplayCount",
}
export type TenancyMode = z.infer<typeof tenancyModeSchema>;

View file

@ -0,0 +1,40 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { env } from './env.mjs'
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants'
export async function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
if (env.SOURCEBOT_TENANCY_MODE !== 'single') {
return NextResponse.next();
}
// Enable these domains when auth is enabled.
if (env.SOURCEBOT_AUTH_ENABLED === 'true' &&
(
url.pathname.startsWith('/login') ||
url.pathname.startsWith('/redeem')
)
) {
return NextResponse.next();
}
const pathSegments = url.pathname.split('/').filter(Boolean);
const currentDomain = pathSegments[0];
// If we're already on the correct domain path, allow
if (currentDomain === SINGLE_TENANT_ORG_DOMAIN) {
return NextResponse.next();
}
url.pathname = `/${SINGLE_TENANT_ORG_DOMAIN}${pathSegments.length > 1 ? '/' + pathSegments.slice(1).join('/') : ''}`;
return NextResponse.redirect(url);
}
export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: [
'/((?!api|_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'
],
}