Single tenancy & auth modes (#233)

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

View file

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

View file

@ -2,7 +2,7 @@
import Ajv from "ajv"; import Ajv from "ajv";
import { auth } from "./auth"; 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 { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
@ -16,7 +16,6 @@ import { decrypt, encrypt } from "@sourcebot/crypto"
import { getConnection } from "./data/connection"; import { getConnection } from "./data/connection";
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { cookies, headers } from "next/headers" import { cookies, headers } from "next/headers"
import { getUser } from "@/data/user";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import Stripe from "stripe"; import Stripe from "stripe";
@ -24,8 +23,8 @@ import { render } from "@react-email/components";
import InviteUserEmail from "./emails/inviteUserEmail"; import InviteUserEmail from "./emails/inviteUserEmail";
import { createTransport } from "nodemailer"; import { createTransport } from "nodemailer";
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { RepositoryQuery } from "./lib/types"; import { TenancyMode } from "./lib/types";
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; 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 { stripeClient } from "./lib/stripe";
import { IS_BILLING_ENABLED } from "./lib/stripe"; import { IS_BILLING_ENABLED } from "./lib/stripe";
@ -33,9 +32,27 @@ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
}); });
export const withAuth = async <T>(fn: (session: Session) => Promise<T>) => { export const withAuth = async <T>(fn: (session: Session) => Promise<T>, allowSingleTenantUnauthedAccess: boolean = false) => {
const session = await auth(); const session = await auth();
if (!session) { 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 notAuthenticated();
} }
return fn(session); return fn(session);
@ -89,34 +106,41 @@ export const withOrgMembership = async <T>(session: Session, domain: string, fn:
}); });
} }
export const isAuthed = async () => { export const withTenancyModeEnforcement = async<T>(mode: TenancyMode, fn: () => Promise<T>) => {
const session = await auth(); if (env.SOURCEBOT_TENANCY_MODE !== mode) {
return session != null; 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> => export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> =>
withAuth(async (session) => { withTenancyModeEnforcement('multi', () =>
const org = await prisma.org.create({ withAuth(async (session) => {
data: { const org = await prisma.org.create({
name, data: {
domain, name,
members: { domain,
create: { members: {
role: "OWNER", create: {
user: { role: "OWNER",
connect: { user: {
id: session.user.id, connect: {
id: session.user.id,
}
} }
} }
} }
} }
} });
});
return { return {
id: org.id, id: org.id,
} }
}); }));
export const updateOrgName = async (name: string, domain: string) => export const updateOrgName = async (name: string, domain: string) =>
withAuth((session) => withAuth((session) =>
@ -139,30 +163,31 @@ export const updateOrgName = async (name: string, domain: string) =>
success: true, success: true,
} }
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* minRequiredRole = */ OrgRole.OWNER)
) );
export const updateOrgDomain = async (newDomain: string, existingDomain: string) => export const updateOrgDomain = async (newDomain: string, existingDomain: string) =>
withAuth((session) => withTenancyModeEnforcement('multi', () =>
withOrgMembership(session, existingDomain, async ({ orgId }) => { withAuth((session) =>
const { success } = await orgDomainSchema.safeParseAsync(newDomain); withOrgMembership(session, existingDomain, async ({ orgId }) => {
if (!success) { 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 },
});
return { return {
statusCode: StatusCodes.BAD_REQUEST, success: true,
errorCode: ErrorCode.INVALID_REQUEST_BODY, }
message: "Invalid organization url", }, /* minRequiredRole = */ OrgRole.OWNER)
} satisfies ServiceError; ));
}
await prisma.org.update({
where: { id: orgId },
data: { domain: newDomain },
});
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER),
)
export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> => export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) => withAuth((session) =>
@ -224,7 +249,6 @@ export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: stri
key: secret.key, key: secret.key,
createdAt: secret.createdAt, createdAt: secret.createdAt,
})); }));
})); }));
export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> => 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; return !!secret;
}) }));
);
export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> => export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) => withAuth((session) =>
@ -360,9 +383,9 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
numLinkedRepos: connection.repos.length, numLinkedRepos: connection.repos.length,
} }
}) })
) );
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}): Promise<RepositoryQuery[] | ServiceError> => export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) =>
withAuth((session) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
const repos = await prisma.repo.findMany({ const repos = await prisma.repo.findMany({
@ -401,8 +424,8 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt
indexedAt: repo.indexedAt ?? undefined, indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus, repoIndexingStatus: repo.repoIndexingStatus,
})); }));
}) }
); ), /* allowSingleTenantUnauthedAccess = */ true);
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
withAuth((session) => withAuth((session) =>
@ -424,7 +447,8 @@ export const createConnection = async (name: string, type: string, connectionCon
return { return {
id: connection.id, id: connection.id,
} }
})); })
);
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) => withAuth((session) =>
@ -695,8 +719,40 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* minRequiredRole = */ OrgRole.OWNER)
); );
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => export const getMe = async () =>
withAuth(async (session) => { 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({ const invite = await prisma.invite.findUnique({
where: { where: {
id: inviteId, id: inviteId,
@ -710,9 +766,9 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
return notFound(); return notFound();
} }
const user = await getUser(session.user.id); const user = await getMe();
if (!user) { if (isServiceError(user)) {
return notFound(); return user;
} }
// Check if the user is the recipient of the invite // 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) => export const getInviteInfo = async (inviteId: string) =>
withAuth(async (session) => { withAuth(async () => {
const user = await getUser(session.user.id); const user = await getMe();
if (!user) { if (isServiceError(user)) {
return notFound(); return user;
} }
const invite = await prisma.invite.findUnique({ const invite = await prisma.invite.findUnique({
@ -880,17 +936,13 @@ export const createOnboardingSubscription = async (domain: string) =>
return notFound(); return notFound();
} }
const user = await getUser(session.user.id); const user = await getMe();
if (!user) { if (isServiceError(user)) {
return notFound(); return user;
} }
if (!stripeClient) { if (!stripeClient) {
return { return stripeClientNotInitialized();
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
} }
const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({ 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) { if (!stripeClient) {
return { return stripeClientNotInitialized();
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
} }
const orgMembers = await prisma.userToOrg.findMany({ const orgMembers = await prisma.userToOrg.findMany({
@ -1042,7 +1090,7 @@ export const createStripeCheckoutSession = async (domain: string) =>
url: stripeSession.url, url: stripeSession.url,
} }
}) })
) );
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> => export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
withAuth((session) => withAuth((session) =>
@ -1058,11 +1106,7 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise<stri
} }
if (!stripeClient) { if (!stripeClient) {
return { return stripeClientNotInitialized();
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
} }
const origin = (await headers()).get('origin') const origin = (await headers()).get('origin')
@ -1096,11 +1140,7 @@ export const getSubscriptionBillingEmail = async (domain: string): Promise<strin
} }
if (!stripeClient) { if (!stripeClient) {
return { return stripeClientNotInitialized();
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
} }
const customer = await stripeClient.customers.retrieve(org.stripeCustomerId); const customer = await stripeClient.customers.retrieve(org.stripeCustomerId);
@ -1125,11 +1165,7 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s
} }
if (!stripeClient) { if (!stripeClient) {
return { return stripeClientNotInitialized();
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
} }
await stripeClient.customers.update(org.stripeCustomerId, { await stripeClient.customers.update(org.stripeCustomerId, {
@ -1351,11 +1387,7 @@ const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.Transactio
} }
if (!stripeClient) { if (!stripeClient) {
return { return stripeClientNotInitialized();
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
message: "Stripe client is not initialized.",
} satisfies ServiceError;
} }
const subscriptions = await stripeClient.subscriptions.list({ const subscriptions = await stripeClient.subscriptions.list({
@ -1401,6 +1433,15 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
} satisfies ServiceError; } 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 } = (() => { const { numRepos, hasToken } = (() => {
switch (connectionType) { switch (connectionType) {
case "github": { case "github": {
@ -1447,15 +1488,6 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
} satisfies ServiceError; } 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; return parsedConfig;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,11 @@ import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
export const RepositoryTable = () => { interface RepositoryTableProps {
isAddNewRepoButtonVisible: boolean;
}
export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTableProps) => {
const domain = useDomain(); const domain = useDomain();
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({ const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
@ -48,31 +52,31 @@ export const RepositoryTable = () => {
const tableColumns = useMemo(() => { const tableColumns = useMemo(() => {
if (reposLoading) { if (reposLoading) {
return columns(domain).map((column) => { return columns(domain, isAddNewRepoButtonVisible).map((column) => {
if ('accessorKey' in column && column.accessorKey === "name") { if ('accessorKey' in column && column.accessorKey === "name") {
return { return {
...column,
cell: () => (
<div className="flex flex-row items-center gap-3 py-2">
<Skeleton className="h-8 w-8 rounded-md" /> {/* Avatar skeleton */}
<Skeleton className="h-4 w-48" /> {/* Repository name skeleton */}
</div>
),
}
}
return {
...column, ...column,
cell: () => ( cell: () => (
<div className="flex flex-row items-center gap-3 py-2"> <div className="flex flex-wrap gap-1.5">
<Skeleton className="h-8 w-8 rounded-md" /> {/* Avatar skeleton */} <Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-4 w-48" /> {/* Repository name skeleton */} </div>
</div>
), ),
}
} }
})
return {
...column,
cell: () => (
<div className="flex flex-wrap gap-1.5">
<Skeleton className="h-5 w-24 rounded-full" />
</div>
),
}
})
} }
return columns(domain); return columns(domain, isAddNewRepoButtonVisible);
}, [reposLoading, domain]); }, [reposLoading, domain]);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,19 +5,19 @@ import NextError from "next/error";
import { useEffect } from "react"; import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) { export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => { useEffect(() => {
Sentry.captureException(error); Sentry.captureException(error);
}, [error]); }, [error]);
return ( return (
<html> <html>
<body> <body>
{/* `NextError` is the default Next.js error page component. Its type {/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a does not expose status codes for errors, we simply pass 0 to render a
generic error message. */} generic error message. */}
<NextError statusCode={0} /> <NextError statusCode={0} />
</body> </body>
</html> </html>
); );
} }

View file

@ -1,5 +1,5 @@
import 'next-auth/jwt'; 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 GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google" import Google from "next-auth/providers/google"
import Credentials from "next-auth/providers/credentials" 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 { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { User } from '@sourcebot/db'; import { OrgRole, User } from '@sourcebot/db';
import 'next-auth/jwt'; import 'next-auth/jwt';
import type { Provider } from "next-auth/providers"; import type { Provider } from "next-auth/providers";
import { verifyCredentialsRequestSchema, verifyCredentialsResponseSchema } from './lib/schemas'; import { verifyCredentialsRequestSchema } from './lib/schemas';
import { createTransport } from 'nodemailer'; import { createTransport } from 'nodemailer';
import { render } from '@react-email/render'; import { render } from '@react-email/render';
import MagicLinkEmail from './emails/magicLinkEmail'; import MagicLinkEmail from './emails/magicLinkEmail';
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
import bcrypt from 'bcrypt';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@ -89,24 +91,45 @@ export const getProviders = () => {
return null; return null;
} }
const { email, password } = body.data; const { email, password } = body.data;
// authorize runs in the edge runtime (where we cannot make DB calls / access environment variables), const user = await prisma.user.findUnique({
// so we need to make a request to the server to verify the credentials. where: { email }
const response = await fetch(new URL('/api/auth/verifyCredentials', env.AUTH_URL), {
method: 'POST',
body: JSON.stringify({ email, password }),
}); });
if (!response.ok) { // The user doesn't exist, so create a new one.
return null; if (!user) {
} const hashedPassword = bcrypt.hashSync(password, 10);
const newUser = await prisma.user.create({
const user = verifyCredentialsResponseSchema.parse(await response.json()); data: {
return { email,
id: user.id, hashedPassword,
email: user.email, }
name: user.name, });
image: user.image,
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; 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 useSecureCookies = env.AUTH_URL?.startsWith("https://") ?? false;
const hostName = env.AUTH_URL ? new URL(env.AUTH_URL).hostname : "localhost"; 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", strategy: "jwt",
}, },
trustHost: true, trustHost: true,
events: {
createUser: onCreateUser,
},
callbacks: { callbacks: {
async jwt({ token, user: _user }) { async jwt({ token, user: _user }) {
const user = _user as User | undefined; const user = _user as User | undefined;

View file

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

View file

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

View file

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

View file

@ -1,13 +1,17 @@
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
export async function register() { export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') { if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('../sentry.server.config'); await import('../sentry.server.config');
} }
if (process.env.NEXT_RUNTIME === 'edge') { if (process.env.NEXT_RUNTIME === 'edge') {
await import('../sentry.edge.config'); await import('../sentry.edge.config');
} }
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import ('./initialize');
}
} }
export const onRequestError = Sentry.captureRequestError; export const onRequestError = Sentry.captureRequestError;

View file

@ -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.", "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'; export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed';
export const SINGLE_TENANT_USER_ID = '1';
export const SINGLE_TENANT_USER_EMAIL = 'default@sourcebot.dev';
export const SINGLE_TENANT_ORG_ID = 1;
export const SINGLE_TENANT_ORG_DOMAIN = '~';
export const SINGLE_TENANT_ORG_NAME = 'default';

View file

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

View file

@ -183,14 +183,6 @@ export const verifyCredentialsRequestSchema = z.object({
password: z.string().min(8), 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 orgNameSchema = z.string().min(2, { message: "Organization name must be at least 3 characters long." });
export const orgDomainSchema = z.string() export const orgDomainSchema = z.string()

View file

@ -1,11 +1,22 @@
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "./errorCodes"; import { ErrorCode } from "./errorCodes";
import { ZodError } from "zod"; import { z, ZodError } from "zod";
export interface ServiceError { export const serviceErrorSchema = z.object({
statusCode: StatusCodes; statusCode: z.number(),
errorCode: ErrorCode; errorCode: z.string(),
message: string; message: z.string(),
});
export type ServiceError = z.infer<typeof serviceErrorSchema>;
/**
* Useful for throwing errors and handling them in error boundaries.
*/
export class ServiceErrorException extends Error {
constructor(public readonly serviceError: ServiceError) {
super(JSON.stringify(serviceError));
}
} }
export const serviceErrorResponse = ({ statusCode, errorCode, message }: ServiceError) => { export const serviceErrorResponse = ({ statusCode, errorCode, message }: ServiceError) => {
@ -107,4 +118,12 @@ export const secretAlreadyExists = (): ServiceError => {
errorCode: ErrorCode.SECRET_ALREADY_EXISTS, errorCode: ErrorCode.SECRET_ALREADY_EXISTS,
message: "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.",
}
} }

View file

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

View file

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