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