diff --git a/.env.development b/.env.development
index aae06e86..d01b5c09 100644
--- a/.env.development
+++ b/.env.development
@@ -80,4 +80,5 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection
# CONFIG_MAX_REPOS_NO_TOKEN=
# SOURCEBOT_ROOT_DOMAIN=
-# NODE_ENV=
\ No newline at end of file
+# NODE_ENV=
+# SOURCEBOT_TENANCY_MODE=mutli
\ No newline at end of file
diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts
index 122675c7..b82ef598 100644
--- a/packages/web/src/actions.ts
+++ b/packages/web/src/actions.ts
@@ -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 (fn: (session: Session) => Promise) => {
+export const withAuth = async (fn: (session: Session) => Promise, 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,34 +106,41 @@ export const withOrgMembership = async (session: Session, domain: string, fn:
});
}
-export const isAuthed = async () => {
- const session = await auth();
- return session != null;
+export const withTenancyModeEnforcement = async(mode: TenancyMode, fn: () => Promise) => {
+ 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> =>
- withAuth(async (session) => {
- const org = await prisma.org.create({
- data: {
- name,
- domain,
- members: {
- create: {
- role: "OWNER",
- user: {
- connect: {
- id: session.user.id,
+ withTenancyModeEnforcement('multi', () =>
+ withAuth(async (session) => {
+ const org = await prisma.org.create({
+ data: {
+ name,
+ domain,
+ members: {
+ create: {
+ role: "OWNER",
+ user: {
+ connect: {
+ id: session.user.id,
+ }
}
}
}
}
- }
- });
+ });
- return {
- id: org.id,
- }
- });
+ return {
+ id: org.id,
+ }
+ }));
export const updateOrgName = async (name: string, domain: string) =>
withAuth((session) =>
@@ -139,30 +163,31 @@ export const updateOrgName = async (name: string, domain: string) =>
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
- )
+ );
export const updateOrgDomain = async (newDomain: string, existingDomain: string) =>
- withAuth((session) =>
- withOrgMembership(session, existingDomain, async ({ orgId }) => {
- const { success } = await orgDomainSchema.safeParseAsync(newDomain);
- if (!success) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_REQUEST_BODY,
- message: "Invalid organization url",
- } satisfies ServiceError;
- }
+ withTenancyModeEnforcement('multi', () =>
+ withAuth((session) =>
+ withOrgMembership(session, existingDomain, async ({ orgId }) => {
+ const { success } = await orgDomainSchema.safeParseAsync(newDomain);
+ if (!success) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "Invalid organization url",
+ } satisfies ServiceError;
+ }
- await prisma.org.update({
- where: { id: orgId },
- data: { domain: newDomain },
- });
+ await prisma.org.update({
+ where: { id: orgId },
+ data: { domain: newDomain },
+ });
- return {
- success: true,
- }
- }, /* minRequiredRole = */ OrgRole.OWNER),
- )
+ return {
+ success: true,
+ }
+ }, /* 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 =>
+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 =>
withAuth((session) =>
@@ -1058,11 +1106,7 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise {
} 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;
}
diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx
index ffa69428..804f306b 100644
--- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx
+++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx
@@ -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 ;
+ 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
+ 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 (
diff --git a/packages/web/src/app/[domain]/components/importSecretDialog.tsx b/packages/web/src/app/[domain]/components/importSecretDialog.tsx
index 264d9d28..853b298e 100644
--- a/packages/web/src/app/[domain]/components/importSecretDialog.tsx
+++ b/packages/web/src/app/[domain]/components/importSecretDialog.tsx
@@ -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,
diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx
index be3954dd..3fdd7fe1 100644
--- a/packages/web/src/app/[domain]/components/navigationMenu.tsx
+++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx
@@ -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 ({
/>
-
-
+ {env.SOURCEBOT_TENANCY_MODE === 'multi' && (
+ <>
+
+
+ >
+ )}
@@ -60,20 +65,24 @@ export const NavigationMenu = async ({
-
-
-
- Connections
-
-
-
-
-
-
- Settings
-
-
-
+ {env.SOURCEBOT_AUTH_ENABLED === 'true' && (
+
+
+
+ Connections
+
+
+
+ )}
+ {env.SOURCEBOT_AUTH_ENABLED === 'true' && (
+
+
+
+ Settings
+
+
+
+ )}
diff --git a/packages/web/src/app/[domain]/components/orgSelector/index.tsx b/packages/web/src/app/[domain]/components/orgSelector/index.tsx
index e4c89908..769a072e 100644
--- a/packages/web/src/app/[domain]/components/orgSelector/index.tsx
+++ b/packages/web/src/app/[domain]/components/orgSelector/index.tsx
@@ -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 (
({
- name: org.name,
- id: org.id,
- domain: org.domain,
+ orgs={user.memberships.map(({ name, domain, id }) => ({
+ name,
+ domain,
+ id,
}))}
activeOrgId={activeOrg.id}
/>
diff --git a/packages/web/src/app/[domain]/connections/[id]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx
index 5e5c8be3..7aa97735 100644
--- a/packages/web/src/app/[domain]/connections/[id]/page.tsx
+++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx
@@ -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
@@ -42,7 +36,6 @@ export default async function ConnectionManagementPage({ params, searchParams }:
const currentTab = searchParams.tab || "overview";
-
return (
diff --git a/packages/web/src/app/[domain]/connections/page.tsx b/packages/web/src/app/[domain]/connections/page.tsx
index 2f37ccbf..772863ce 100644
--- a/packages/web/src/app/[domain]/connections/page.tsx
+++ b/packages/web/src/app/[domain]/connections/page.tsx
@@ -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 ;
+ throw new ServiceErrorException(connections);
}
return (
diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx
index b1b66794..db4aff6f 100644
--- a/packages/web/src/app/[domain]/layout.tsx
+++ b/packages/web/src/app/[domain]/layout.tsx
@@ -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,27 +27,27 @@ export default async function Layout({
const org = await getOrgFromDomain(domain);
if (!org) {
- return
- }
-
-
- const session = await auth();
- if (!session) {
- return
+ return notFound();
}
+ if (env.SOURCEBOT_AUTH_ENABLED === 'true') {
+ const session = await auth();
+ if (!session) {
+ redirect('/login');
+ }
- const membership = await prisma.userToOrg.findUnique({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: session.user.id
+ const membership = await prisma.userToOrg.findUnique({
+ where: {
+ orgId_userId: {
+ orgId: org.id,
+ userId: session.user.id
+ }
}
- }
- });
+ });
- if (!membership) {
- return
+ if (!membership) {
+ return notFound();
+ }
}
if (!org.isOnboarded) {
diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx
index 3d380d4b..abcf0c91 100644
--- a/packages/web/src/app/[domain]/page.tsx
+++ b/packages/web/src/app/[domain]/page.tsx
@@ -43,48 +43,48 @@ export default async function Home({ params: { domain } }: { params: { domain: s
title="Search in files or paths"
>
- test todo (both test and todo)
+ test todo (both test and todo)
- test or todo (either test or todo)
+ test or todo (either test or todo)
- {`"exit boot"`} (exact match)
+ {`"exit boot"`} (exact match)
- TODO case:yes (case sensitive)
+ TODO case:yes (case sensitive)
- file:README setup (by filename)
+ file:README setup (by filename)
- repo:torvalds/linux test (by repo)
+ repo:torvalds/linux test (by repo)
- lang:typescript (by language)
+ lang:typescript (by language)
- rev:HEAD (by branch or tag)
+ rev:HEAD (by branch or tag)
- file:{`\\.py$`} {`(files that end in ".py")`}
+ file:{`\\.py$`} {`(files that end in ".py")`}
- sym:main {`(symbols named "main")`}
+ sym:main {`(symbols named "main")`}
- todo -lang:c (negate filter)
+ todo -lang:c (negate filter)
- content:README (search content only)
+ content:README (search content only)
@@ -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 (
{children}
diff --git a/packages/web/src/app/[domain]/repos/columns.tsx b/packages/web/src/app/[domain]/repos/columns.tsx
index 3ac63f8b..2c8a3776 100644
--- a/packages/web/src/app/[domain]/repos/columns.tsx
+++ b/packages/web/src/app/[domain]/repos/columns.tsx
@@ -93,13 +93,13 @@ const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => {
)
}
-export const columns = (domain: string): ColumnDef[] => [
+export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): ColumnDef[] => [
{
accessorKey: "name",
header: () => (
Repository
-
+ {isAddNewRepoButtonVisible &&
}
),
cell: ({ row }) => {
diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx
index 20da1248..9ba31340 100644
--- a/packages/web/src/app/[domain]/repos/page.tsx
+++ b/packages/web/src/app/[domain]/repos/page.tsx
@@ -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
diff --git a/packages/web/src/app/[domain]/repos/repositoryTable.tsx b/packages/web/src/app/[domain]/repos/repositoryTable.tsx
index 59ee672b..14cc6a33 100644
--- a/packages/web/src/app/[domain]/repos/repositoryTable.tsx
+++ b/packages/web/src/app/[domain]/repos/repositoryTable.tsx
@@ -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,31 +52,31 @@ 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 {
+ return {
+ ...column,
+ cell: () => (
+
+ {/* Avatar skeleton */}
+ {/* Repository name skeleton */}
+
+ ),
+ }
+ }
+
+ return {
...column,
cell: () => (
-
- {/* Avatar skeleton */}
- {/* Repository name skeleton */}
-
+
+
+
),
- }
- }
-
- return {
- ...column,
- cell: () => (
-
-
-
- ),
}
- })
+ })
}
- return columns(domain);
+ return columns(domain, isAddNewRepoButtonVisible);
}, [reposLoading, domain]);
diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx
index 45ddfb73..90ad8884 100644
--- a/packages/web/src/app/[domain]/settings/(general)/page.tsx
+++ b/packages/web/src/app/[domain]/settings/(general)/page.tsx
@@ -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 Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException(currentUserRole);
}
const org = await getOrgFromDomain(domain)
if (!org) {
- return Failed to fetch organization. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException({
+ message: "Failed to fetch organization.",
+ statusCode: 500,
+ errorCode: ErrorCode.NOT_FOUND,
+ });
}
return (
diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx
index e7214018..5f66e6cc 100644
--- a/packages/web/src/app/[domain]/settings/billing/page.tsx
+++ b/packages/web/src/app/[domain]/settings/billing/page.tsx
@@ -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 Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException(subscription);
}
if (!subscription) {
- return todo
+ throw new Error("Subscription not found");
}
const currentUserRole = await getCurrentUserRole(domain)
if (isServiceError(currentUserRole)) {
- return Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException(currentUserRole);
}
const billingEmail = await getSubscriptionBillingEmail(domain);
if (isServiceError(billingEmail)) {
- return Failed to fetch billing email. Please contact us at team@sourcebot.dev if this issue persists.
+ throw new ServiceErrorException(billingEmail);
}
return (
diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx
index a1227420..cab223e6 100644
--- a/packages/web/src/app/[domain]/settings/members/page.tsx
+++ b/packages/web/src/app/[domain]/settings/members/page.tsx
@@ -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 members = await getOrgMembers(domain);
const org = await getOrgFromDomain(domain);
if (!org) {
- return null;
+ throw new Error("Organization not found");
}
- const user = await getUser(session.user.id);
- if (!user) {
- return null;
+ const me = await getMe();
+ if (isServiceError(me)) {
+ throw new ServiceErrorException(me);
}
- const userRoleInOrg = await getUserRoleInOrg(user.id, org.id);
+ const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
if (!userRoleInOrg) {
- return null;
+ throw new Error("User role not found");
}
+ const members = await getOrgMembers(domain);
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
diff --git a/packages/web/src/app/[domain]/settings/secrets/page.tsx b/packages/web/src/app/[domain]/settings/secrets/page.tsx
index 86525df8..c9aeab77 100644
--- a/packages/web/src/app/[domain]/settings/secrets/page.tsx
+++ b/packages/web/src/app/[domain]/settings/secrets/page.tsx
@@ -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 (
diff --git a/packages/web/src/app/[domain]/upgrade/page.tsx b/packages/web/src/app/[domain]/upgrade/page.tsx
index cd46aaf9..cd8f238b 100644
--- a/packages/web/src/app/[domain]/upgrade/page.tsx
+++ b/packages/web/src/app/[domain]/upgrade/page.tsx
@@ -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
-
+ {env.SOURCEBOT_TENANCY_MODE === 'multi' && (
+
+ )}
{
- 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,
- };
- }
-
-}
\ No newline at end of file
diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts
index ba13f3e6..bab9bc9d 100644
--- a/packages/web/src/app/api/(server)/repos/route.ts
+++ b/packages/web/src/app/api/(server)/repos/route.ts
@@ -22,5 +22,5 @@ const getRepos = (domain: string) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const response = await listRepositories(orgId);
return response;
- })
- );
\ No newline at end of file
+ }
+ ), /* allowSingleTenantUnauthedAccess */ true);
\ No newline at end of file
diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts
index 4bb119a9..813640e7 100644
--- a/packages/web/src/app/api/(server)/search/route.ts
+++ b/packages/web/src/app/api/(server)/search/route.ts
@@ -30,4 +30,5 @@ const postSearch = (request: SearchRequest, domain: string) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const response = await search(request, orgId);
return response;
- }))
\ No newline at end of file
+ }
+ ), /* allowSingleTenantUnauthedAccess */ true);
\ No newline at end of file
diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts
index 858857f6..1c9318b4 100644
--- a/packages/web/src/app/api/(server)/source/route.ts
+++ b/packages/web/src/app/api/(server)/source/route.ts
@@ -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);
diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx
new file mode 100644
index 00000000..4e084150
--- /dev/null
+++ b/packages/web/src/app/error.tsx
@@ -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 (
+
+
+
+
+ )
+}
+
+interface ErrorCardProps {
+ message: string
+ errorCode?: string | number
+ statusCode?: string | number
+ onReloadButtonClicked: () => void
+}
+
+function ErrorCard({ message, errorCode, statusCode, onReloadButtonClicked }: ErrorCardProps) {
+ const [copied, setCopied] = useState(null)
+
+ const copyToClipboard = (text: string, field: string) => {
+ navigator.clipboard.writeText(text)
+ setCopied(field)
+ setTimeout(() => setCopied(null), 2000)
+ }
+
+ return (
+
+
+
+
+ Unexpected Error
+
+
+ An unexpected error occurred. Please reload the page and try again. If the issue persists, please contact us.
+
+
+
+
+ copyToClipboard(message, "message")}
+ copied={copied === "message"}
+ />
+
+ {errorCode && (
+ copyToClipboard(errorCode.toString(), "errorCode")}
+ copied={copied === "errorCode"}
+ />
+ )}
+
+ {statusCode && (
+ copyToClipboard(statusCode.toString(), "statusCode")}
+ copied={copied === "statusCode"}
+ />
+ )}
+
+
+
+
+ )
+}
+
+interface ErrorFieldProps {
+ label: string
+ value: string | number
+ onCopy: () => void
+ copied: boolean
+}
+
+function ErrorField({ label, value, onCopy, copied }: ErrorFieldProps) {
+ return (
+
+
{label}
+
+
{value}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web/src/app/global-error.tsx b/packages/web/src/app/global-error.tsx
index 9bda5fee..bfea313f 100644
--- a/packages/web/src/app/global-error.tsx
+++ b/packages/web/src/app/global-error.tsx
@@ -5,19 +5,19 @@ import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
- useEffect(() => {
- Sentry.captureException(error);
- }, [error]);
+ useEffect(() => {
+ Sentry.captureException(error);
+ }, [error]);
- return (
-
-
- {/* `NextError` is the default Next.js error page component. Its type
+ return (
+
+
+ {/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
-
-
-
- );
+
+
+
+ );
}
\ No newline at end of file
diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts
index bcac55b3..39b76523 100644
--- a/packages/web/src/auth.ts
+++ b/packages/web/src/auth.ts
@@ -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';
@@ -89,24 +91,45 @@ export const getProviders = () => {
return null;
}
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) {
- return null;
- }
-
- const user = verifyCredentialsResponseSchema.parse(await response.json());
- return {
- id: user.id,
- email: user.email,
- name: user.name,
- image: user.image,
+
+ // 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;
+ }
+
+ return {
+ id: user.id,
+ email: user.email,
+ 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;
diff --git a/packages/web/src/data/user.ts b/packages/web/src/data/user.ts
deleted file mode 100644
index 624f5b9f..00000000
--- a/packages/web/src/data/user.ts
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs
index 4c27285c..612b77f5 100644
--- a/packages/web/src/env.mjs
+++ b/packages/web/src/env.mjs
@@ -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.
diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts
new file mode 100644
index 00000000..6ef83e49
--- /dev/null
+++ b/packages/web/src/initialize.ts
@@ -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();
+}
\ No newline at end of file
diff --git a/packages/web/src/instrumentation.ts b/packages/web/src/instrumentation.ts
index 8aff09f0..0c87feb9 100644
--- a/packages/web/src/instrumentation.ts
+++ b/packages/web/src/instrumentation.ts
@@ -1,13 +1,17 @@
import * as Sentry from '@sentry/nextjs';
export async function register() {
- if (process.env.NEXT_RUNTIME === 'nodejs') {
- await import('../sentry.server.config');
- }
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
+ await import('../sentry.server.config');
+ }
- if (process.env.NEXT_RUNTIME === 'edge') {
- await import('../sentry.edge.config');
- }
+ 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;
diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts
index 1b8ff041..faee5be9 100644
--- a/packages/web/src/lib/constants.ts
+++ b/packages/web/src/lib/constants.ts
@@ -22,4 +22,10 @@ export const TEAM_FEATURES = [
"Built on-top of zoekt, Google's code search engine. Blazingly fast and powerful (regex, symbol) code search.",
]
-export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed';
\ No newline at end of file
+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';
\ No newline at end of file
diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts
index e978a175..65449088 100644
--- a/packages/web/src/lib/errorCodes.ts
+++ b/packages/web/src/lib/errorCodes.ts
@@ -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',
}
diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts
index 0345aac7..93c52345 100644
--- a/packages/web/src/lib/schemas.ts
+++ b/packages/web/src/lib/schemas.ts
@@ -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()
diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts
index 325d8da3..52a2b506 100644
--- a/packages/web/src/lib/serviceError.ts
+++ b/packages/web/src/lib/serviceError.ts
@@ -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;
+
+/**
+ * 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) => {
@@ -107,4 +118,12 @@ export const secretAlreadyExists = (): ServiceError => {
errorCode: ErrorCode.SECRET_ALREADY_EXISTS,
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.",
+ }
}
\ No newline at end of file
diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts
index 5614a24d..b4720577 100644
--- a/packages/web/src/lib/types.ts
+++ b/packages/web/src/lib/types.ts
@@ -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";
@@ -25,4 +26,6 @@ export type GetVersionResponse = z.infer;
export enum SearchQueryParams {
query = "query",
maxMatchDisplayCount = "maxMatchDisplayCount",
-}
\ No newline at end of file
+}
+
+export type TenancyMode = z.infer;
\ No newline at end of file
diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts
new file mode 100644
index 00000000..b8c1580c
--- /dev/null
+++ b/packages/web/src/middleware.ts
@@ -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).*)'
+ ],
+}
\ No newline at end of file