mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
Single tenancy & auth modes (#233)
This commit is contained in:
parent
583df1dd77
commit
4ecd7009cd
35 changed files with 690 additions and 425 deletions
|
|
@ -80,4 +80,5 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection
|
|||
|
||||
# CONFIG_MAX_REPOS_NO_TOKEN=
|
||||
# SOURCEBOT_ROOT_DOMAIN=
|
||||
# NODE_ENV=
|
||||
# NODE_ENV=
|
||||
# SOURCEBOT_TENANCY_MODE=mutli
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Ajv from "ajv";
|
||||
import { auth } from "./auth";
|
||||
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists } from "@/lib/serviceError";
|
||||
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists, stripeClientNotInitialized } from "@/lib/serviceError";
|
||||
import { prisma } from "@/prisma";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
|
|
@ -16,7 +16,6 @@ import { decrypt, encrypt } from "@sourcebot/crypto"
|
|||
import { getConnection } from "./data/connection";
|
||||
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { cookies, headers } from "next/headers"
|
||||
import { getUser } from "@/data/user";
|
||||
import { Session } from "next-auth";
|
||||
import { env } from "@/env.mjs";
|
||||
import Stripe from "stripe";
|
||||
|
|
@ -24,8 +23,8 @@ import { render } from "@react-email/components";
|
|||
import InviteUserEmail from "./emails/inviteUserEmail";
|
||||
import { createTransport } from "nodemailer";
|
||||
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
||||
import { RepositoryQuery } from "./lib/types";
|
||||
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants";
|
||||
import { TenancyMode } from "./lib/types";
|
||||
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants";
|
||||
import { stripeClient } from "./lib/stripe";
|
||||
import { IS_BILLING_ENABLED } from "./lib/stripe";
|
||||
|
||||
|
|
@ -33,9 +32,27 @@ const ajv = new Ajv({
|
|||
validateFormats: false,
|
||||
});
|
||||
|
||||
export const withAuth = async <T>(fn: (session: Session) => Promise<T>) => {
|
||||
export const withAuth = async <T>(fn: (session: Session) => Promise<T>, allowSingleTenantUnauthedAccess: boolean = false) => {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
if (
|
||||
env.SOURCEBOT_TENANCY_MODE === 'single' &&
|
||||
env.SOURCEBOT_AUTH_ENABLED === 'false' &&
|
||||
allowSingleTenantUnauthedAccess === true
|
||||
) {
|
||||
// To allow for unauthed acccess in single-tenant mode, we can
|
||||
// create a fake session with the default user. This user has membership
|
||||
// in the default org.
|
||||
// @see: initialize.ts
|
||||
return fn({
|
||||
user: {
|
||||
id: SINGLE_TENANT_USER_ID,
|
||||
email: SINGLE_TENANT_USER_EMAIL,
|
||||
},
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return notAuthenticated();
|
||||
}
|
||||
return fn(session);
|
||||
|
|
@ -89,34 +106,41 @@ export const withOrgMembership = async <T>(session: Session, domain: string, fn:
|
|||
});
|
||||
}
|
||||
|
||||
export const isAuthed = async () => {
|
||||
const session = await auth();
|
||||
return session != null;
|
||||
export const withTenancyModeEnforcement = async<T>(mode: TenancyMode, fn: () => Promise<T>) => {
|
||||
if (env.SOURCEBOT_TENANCY_MODE !== mode) {
|
||||
return {
|
||||
statusCode: StatusCodes.FORBIDDEN,
|
||||
errorCode: ErrorCode.ACTION_DISALLOWED_IN_TENANCY_MODE,
|
||||
message: "This action is not allowed in the current tenancy mode.",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
return fn();
|
||||
}
|
||||
|
||||
export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> =>
|
||||
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) {
|
||||
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 },
|
||||
});
|
||||
|
||||
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 {
|
||||
success: true,
|
||||
}
|
||||
}, /* minRequiredRole = */ OrgRole.OWNER),
|
||||
)
|
||||
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<RepositoryQuery[] | ServiceError> =>
|
||||
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const repos = await prisma.repo.findMany({
|
||||
|
|
@ -401,8 +424,8 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt
|
|||
indexedAt: repo.indexedAt ?? undefined,
|
||||
repoIndexingStatus: repo.repoIndexingStatus,
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
), /* allowSingleTenantUnauthedAccess = */ true);
|
||||
|
||||
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
|
||||
withAuth((session) =>
|
||||
|
|
@ -424,7 +447,8 @@ export const createConnection = async (name: string, type: string, connectionCon
|
|||
return {
|
||||
id: connection.id,
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||
withAuth((session) =>
|
||||
|
|
@ -695,8 +719,40 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
|
|||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||
);
|
||||
|
||||
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
|
||||
export const getMe = async () =>
|
||||
withAuth(async (session) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
include: {
|
||||
orgs: {
|
||||
include: {
|
||||
org: true,
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
memberships: user.orgs.map((org) => ({
|
||||
id: org.orgId,
|
||||
role: org.role,
|
||||
domain: org.org.domain,
|
||||
name: org.org.name,
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
|
||||
withAuth(async () => {
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: {
|
||||
id: inviteId,
|
||||
|
|
@ -710,9 +766,9 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
|||
return notFound();
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return notFound();
|
||||
const user = await getMe();
|
||||
if (isServiceError(user)) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// Check if the user is the recipient of the invite
|
||||
|
|
@ -765,10 +821,10 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
|||
});
|
||||
|
||||
export const getInviteInfo = async (inviteId: string) =>
|
||||
withAuth(async (session) => {
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return notFound();
|
||||
withAuth(async () => {
|
||||
const user = await getMe();
|
||||
if (isServiceError(user)) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const invite = await prisma.invite.findUnique({
|
||||
|
|
@ -880,17 +936,13 @@ export const createOnboardingSubscription = async (domain: string) =>
|
|||
return notFound();
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return notFound();
|
||||
const user = await getMe();
|
||||
if (isServiceError(user)) {
|
||||
return user;
|
||||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
return {
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
|
||||
message: "Stripe client is not initialized.",
|
||||
} satisfies ServiceError;
|
||||
return stripeClientNotInitialized();
|
||||
}
|
||||
|
||||
const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({
|
||||
|
|
@ -992,11 +1044,7 @@ export const createStripeCheckoutSession = async (domain: string) =>
|
|||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
return {
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
|
||||
message: "Stripe client is not initialized.",
|
||||
} satisfies ServiceError;
|
||||
return stripeClientNotInitialized();
|
||||
}
|
||||
|
||||
const orgMembers = await prisma.userToOrg.findMany({
|
||||
|
|
@ -1042,7 +1090,7 @@ export const createStripeCheckoutSession = async (domain: string) =>
|
|||
url: stripeSession.url,
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
|
||||
withAuth((session) =>
|
||||
|
|
@ -1058,11 +1106,7 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise<stri
|
|||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
return {
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
|
||||
message: "Stripe client is not initialized.",
|
||||
} satisfies ServiceError;
|
||||
return stripeClientNotInitialized();
|
||||
}
|
||||
|
||||
const origin = (await headers()).get('origin')
|
||||
|
|
@ -1096,11 +1140,7 @@ export const getSubscriptionBillingEmail = async (domain: string): Promise<strin
|
|||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
return {
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
|
||||
message: "Stripe client is not initialized.",
|
||||
} satisfies ServiceError;
|
||||
return stripeClientNotInitialized();
|
||||
}
|
||||
|
||||
const customer = await stripeClient.customers.retrieve(org.stripeCustomerId);
|
||||
|
|
@ -1125,11 +1165,7 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s
|
|||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
return {
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
|
||||
message: "Stripe client is not initialized.",
|
||||
} satisfies ServiceError;
|
||||
return stripeClientNotInitialized();
|
||||
}
|
||||
|
||||
await stripeClient.customers.update(org.stripeCustomerId, {
|
||||
|
|
@ -1351,11 +1387,7 @@ const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.Transactio
|
|||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
return {
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED,
|
||||
message: "Stripe client is not initialized.",
|
||||
} satisfies ServiceError;
|
||||
return stripeClientNotInitialized();
|
||||
}
|
||||
|
||||
const subscriptions = await stripeClient.subscriptions.list({
|
||||
|
|
@ -1401,6 +1433,15 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
|
|||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const isValidConfig = ajv.validate(schema, parsedConfig);
|
||||
if (!isValidConfig) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const { numRepos, hasToken } = (() => {
|
||||
switch (connectionType) {
|
||||
case "github": {
|
||||
|
|
@ -1447,15 +1488,6 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
|
|||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const isValidConfig = ajv.validate(schema, parsedConfig);
|
||||
if (!isValidConfig) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
return parsedConfig;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { Separator } from '@/components/ui/separator';
|
|||
import { getFileSource, listRepositories } from '@/lib/server/searchService';
|
||||
import { base64Decode, isServiceError } from "@/lib/utils";
|
||||
import { CodePreview } from "./codePreview";
|
||||
import { PageNotFound } from "@/app/[domain]/components/pageNotFound";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
import { LuFileX2, LuBookX } from "react-icons/lu";
|
||||
import { getOrgFromDomain } from "@/data/org";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { ServiceErrorException } from "@/lib/serviceError";
|
||||
interface BrowsePageProps {
|
||||
params: {
|
||||
path: string[];
|
||||
|
|
@ -22,7 +22,7 @@ export default async function BrowsePage({
|
|||
const rawPath = decodeURIComponent(params.path.join('/'));
|
||||
const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//);
|
||||
if (sentinalIndex === -1) {
|
||||
return <PageNotFound />;
|
||||
notFound();
|
||||
}
|
||||
|
||||
const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@');
|
||||
|
|
@ -48,19 +48,14 @@ export default async function BrowsePage({
|
|||
|
||||
const org = await getOrgFromDomain(params.domain);
|
||||
if (!org) {
|
||||
return <PageNotFound />
|
||||
notFound();
|
||||
}
|
||||
|
||||
// @todo (bkellam) : We should probably have a endpoint to fetch repository metadata
|
||||
// given it's name or id.
|
||||
const reposResponse = await listRepositories(org.id);
|
||||
if (isServiceError(reposResponse)) {
|
||||
// @todo : proper error handling
|
||||
return (
|
||||
<>
|
||||
Error: {reposResponse.message}
|
||||
</>
|
||||
)
|
||||
throw new ServiceErrorException(reposResponse);
|
||||
}
|
||||
const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName);
|
||||
|
||||
|
|
@ -145,12 +140,7 @@ const CodePreviewWrapper = async ({
|
|||
)
|
||||
}
|
||||
|
||||
// @todo : proper error handling
|
||||
return (
|
||||
<>
|
||||
Error: {fileSourceResponse.message}
|
||||
</>
|
||||
)
|
||||
throw new ServiceErrorException(fileSourceResponse);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ProgressNavIndicator } from "./progressNavIndicator";
|
|||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { TrialNavIndicator } from "./trialNavIndicator";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
import { env } from "@/env.mjs";
|
||||
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
||||
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
||||
|
||||
|
|
@ -39,10 +40,14 @@ export const NavigationMenu = async ({
|
|||
/>
|
||||
</Link>
|
||||
|
||||
<OrgSelector
|
||||
domain={domain}
|
||||
/>
|
||||
<Separator orientation="vertical" className="h-6 mx-2" />
|
||||
{env.SOURCEBOT_TENANCY_MODE === 'multi' && (
|
||||
<>
|
||||
<OrgSelector
|
||||
domain={domain}
|
||||
/>
|
||||
<Separator orientation="vertical" className="h-6 mx-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<NavigationMenuBase>
|
||||
<NavigationMenuList>
|
||||
|
|
@ -60,20 +65,24 @@ export const NavigationMenu = async ({
|
|||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href={`/${domain}/connections`} legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Connections
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href={`/${domain}/settings`} legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Settings
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
{env.SOURCEBOT_AUTH_ENABLED === 'true' && (
|
||||
<NavigationMenuItem>
|
||||
<Link href={`/${domain}/connections`} legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Connections
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
{env.SOURCEBOT_AUTH_ENABLED === 'true' && (
|
||||
<NavigationMenuItem>
|
||||
<Link href={`/${domain}/settings`} legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Settings
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenuBase>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { auth } from "@/auth";
|
||||
import { getUserOrgs } from "../../../../data/user";
|
||||
import { OrgSelectorDropdown } from "./orgSelectorDropdown";
|
||||
import { prisma } from "@/prisma";
|
||||
import { getMe } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
|
||||
interface OrgSelectorProps {
|
||||
domain: string;
|
||||
|
|
@ -10,12 +10,11 @@ interface OrgSelectorProps {
|
|||
export const OrgSelector = async ({
|
||||
domain,
|
||||
}: OrgSelectorProps) => {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
const user = await getMe();
|
||||
if (isServiceError(user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orgs = await getUserOrgs(session.user.id);
|
||||
const activeOrg = await prisma.org.findUnique({
|
||||
where: {
|
||||
domain,
|
||||
|
|
@ -28,10 +27,10 @@ export const OrgSelector = async ({
|
|||
|
||||
return (
|
||||
<OrgSelectorDropdown
|
||||
orgs={orgs.map((org) => ({
|
||||
name: org.name,
|
||||
id: org.id,
|
||||
domain: org.domain,
|
||||
orgs={user.memberships.map(({ name, domain, id }) => ({
|
||||
name,
|
||||
domain,
|
||||
id,
|
||||
}))}
|
||||
activeOrgId={activeOrg.id}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { ConfigSetting } from "./components/configSetting"
|
|||
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"
|
||||
import { DisplayNameSetting } from "./components/displayNameSetting"
|
||||
import { RepoList } from "./components/repoList"
|
||||
import { auth } from "@/auth"
|
||||
import { getConnectionByDomain } from "@/data/connection"
|
||||
import { Overview } from "./components/overview"
|
||||
|
||||
|
|
@ -30,11 +29,6 @@ interface ConnectionManagementPageProps {
|
|||
}
|
||||
|
||||
export default async function ConnectionManagementPage({ params, searchParams }: ConnectionManagementPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connection = await getConnectionByDomain(Number(params.id), params.domain);
|
||||
if (!connection) {
|
||||
return <NotFound className="flex w-full h-full items-center justify-center" message="Connection not found" />
|
||||
|
|
@ -42,7 +36,6 @@ export default async function ConnectionManagementPage({ params, searchParams }:
|
|||
|
||||
const currentTab = searchParams.tab || "overview";
|
||||
|
||||
|
||||
return (
|
||||
<Tabs value={currentTab} className="w-full">
|
||||
<Header className="mb-6" withTopMargin={false}>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { ConnectionList } from "./components/connectionList";
|
||||
import { Header } from "../components/header";
|
||||
import { NewConnectionCard } from "./components/newConnectionCard";
|
||||
import NotFoundPage from "@/app/not-found";
|
||||
import { getConnections } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { ServiceErrorException } from "@/lib/serviceError";
|
||||
|
||||
export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) {
|
||||
const connections = await getConnections(domain);
|
||||
if (isServiceError(connections)) {
|
||||
return <NotFoundPage />;
|
||||
throw new ServiceErrorException(connections);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 <PageNotFound />
|
||||
return notFound();
|
||||
}
|
||||
|
||||
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return <PageNotFound />
|
||||
}
|
||||
|
||||
|
||||
const membership = await prisma.userToOrg.findUnique({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: org.id,
|
||||
userId: session.user.id
|
||||
}
|
||||
if (env.SOURCEBOT_AUTH_ENABLED === 'true') {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
redirect('/login');
|
||||
}
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return <PageNotFound />
|
||||
const membership = await prisma.userToOrg.findUnique({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: org.id,
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return notFound();
|
||||
}
|
||||
}
|
||||
|
||||
if (!org.isOnboarded) {
|
||||
|
|
|
|||
|
|
@ -43,48 +43,48 @@ export default async function Home({ params: { domain } }: { params: { domain: s
|
|||
title="Search in files or paths"
|
||||
>
|
||||
<QueryExample>
|
||||
<Query query="test todo">test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
||||
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="test or todo">test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
||||
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query={`"exit boot"`}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
||||
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="TODO case:yes">TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
||||
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
||||
</QueryExample>
|
||||
</HowToSection>
|
||||
<HowToSection
|
||||
title="Filter results"
|
||||
>
|
||||
<QueryExample>
|
||||
<Query query="file:README setup"><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
||||
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="repo:torvalds/linux test"><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
||||
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="lang:typescript"><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
||||
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="rev:HEAD"><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
||||
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
||||
</QueryExample>
|
||||
</HowToSection>
|
||||
<HowToSection
|
||||
title="Advanced"
|
||||
>
|
||||
<QueryExample>
|
||||
<Query query="file:\.py$"><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
||||
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="sym:main"><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
||||
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="todo -lang:c">todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
||||
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="content:README"><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
||||
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
||||
</QueryExample>
|
||||
</HowToSection>
|
||||
</div>
|
||||
|
|
@ -130,10 +130,10 @@ const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
|
|||
)
|
||||
}
|
||||
|
||||
const Query = ({ query, children }: { query: string, children: React.ReactNode }) => {
|
||||
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/search?query=${query}`}
|
||||
href={`/${domain}/search?query=${query}`}
|
||||
className="cursor-pointer hover:underline"
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -93,13 +93,13 @@ const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => {
|
|||
)
|
||||
}
|
||||
|
||||
export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
||||
export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): ColumnDef<RepositoryColumnInfo>[] => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => (
|
||||
<div className="flex items-center w-[400px]">
|
||||
<span>Repository</span>
|
||||
<AddRepoButton />
|
||||
{isAddNewRepoButtonVisible && <AddRepoButton />}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { RepositoryTable } from "./repositoryTable";
|
|||
import { getOrgFromDomain } from "@/data/org";
|
||||
import { PageNotFound } from "../components/pageNotFound";
|
||||
import { Header } from "../components/header";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) {
|
||||
const org = await getOrgFromDomain(domain);
|
||||
if (!org) {
|
||||
|
|
@ -15,7 +17,9 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
|
|||
</Header>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full">
|
||||
<RepositoryTable />
|
||||
<RepositoryTable
|
||||
isAddNewRepoButtonVisible={env.SOURCEBOT_AUTH_ENABLED === 'true'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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: () => (
|
||||
<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,
|
||||
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>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</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]);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { auth } from "@/auth";
|
||||
import { ChangeOrgNameCard } from "./components/changeOrgNameCard";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { getCurrentUserRole } from "@/actions";
|
||||
import { getOrgFromDomain } from "@/data/org";
|
||||
import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
import { ServiceErrorException } from "@/lib/serviceError";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
interface GeneralSettingsPageProps {
|
||||
params: {
|
||||
domain: string;
|
||||
|
|
@ -13,19 +13,18 @@ interface GeneralSettingsPageProps {
|
|||
}
|
||||
|
||||
export default async function GeneralSettingsPage({ params: { domain } }: GeneralSettingsPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentUserRole = await getCurrentUserRole(domain)
|
||||
if (isServiceError(currentUserRole)) {
|
||||
return <div>Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||
throw new ServiceErrorException(currentUserRole);
|
||||
}
|
||||
|
||||
const org = await getOrgFromDomain(domain)
|
||||
if (!org) {
|
||||
return <div>Failed to fetch organization. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||
throw new ServiceErrorException({
|
||||
message: "Failed to fetch organization.",
|
||||
statusCode: 500,
|
||||
errorCode: ErrorCode.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { isServiceError } from "@/lib/utils"
|
|||
import { ChangeBillingEmailCard } from "./changeBillingEmailCard"
|
||||
import { notFound } from "next/navigation"
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe"
|
||||
|
||||
import { ServiceErrorException } from "@/lib/serviceError"
|
||||
export const metadata: Metadata = {
|
||||
title: "Billing | Settings",
|
||||
description: "Manage your subscription and billing information",
|
||||
|
|
@ -29,21 +29,21 @@ export default async function BillingPage({
|
|||
const subscription = await getSubscriptionData(domain)
|
||||
|
||||
if (isServiceError(subscription)) {
|
||||
return <div>Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||
throw new ServiceErrorException(subscription);
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return <div>todo</div>
|
||||
throw new Error("Subscription not found");
|
||||
}
|
||||
|
||||
const currentUserRole = await getCurrentUserRole(domain)
|
||||
if (isServiceError(currentUserRole)) {
|
||||
return <div>Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||
throw new ServiceErrorException(currentUserRole);
|
||||
}
|
||||
|
||||
const billingEmail = await getSubscriptionBillingEmail(domain);
|
||||
if (isServiceError(billingEmail)) {
|
||||
return <div>Failed to fetch billing email. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||
throw new ServiceErrorException(billingEmail);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import { MembersList } from "./components/membersList";
|
||||
import { getOrgMembers } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { auth } from "@/auth";
|
||||
import { getUser, getUserRoleInOrg } from "@/data/user";
|
||||
import { getOrgFromDomain } from "@/data/org";
|
||||
import { InviteMemberCard } from "./components/inviteMemberCard";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { TabSwitcher } from "@/components/ui/tab-switcher";
|
||||
import { InvitesList } from "./components/invitesList";
|
||||
import { getOrgInvites } from "@/actions";
|
||||
import { getOrgInvites, getMe } from "@/actions";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
import { ServiceErrorException } from "@/lib/serviceError";
|
||||
interface MembersSettingsPageProps {
|
||||
params: {
|
||||
domain: string
|
||||
|
|
@ -20,34 +19,29 @@ interface MembersSettingsPageProps {
|
|||
}
|
||||
|
||||
export default async function MembersSettingsPage({ params: { domain }, searchParams: { tab } }: MembersSettingsPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return null;
|
||||
const org = await getOrgFromDomain(domain);
|
||||
if (!org) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const me = await getMe();
|
||||
if (isServiceError(me)) {
|
||||
throw new ServiceErrorException(me);
|
||||
}
|
||||
|
||||
const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
|
||||
if (!userRoleInOrg) {
|
||||
throw new Error("User role not found");
|
||||
}
|
||||
|
||||
const members = await getOrgMembers(domain);
|
||||
const org = await getOrgFromDomain(domain);
|
||||
if (!org) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userRoleInOrg = await getUserRoleInOrg(user.id, org.id);
|
||||
if (!userRoleInOrg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isServiceError(members)) {
|
||||
return null;
|
||||
throw new ServiceErrorException(members);
|
||||
}
|
||||
|
||||
const invites = await getOrgInvites(domain);
|
||||
if (isServiceError(invites)) {
|
||||
return null;
|
||||
throw new ServiceErrorException(invites);
|
||||
}
|
||||
|
||||
const currentTab = tab || "members";
|
||||
|
|
@ -78,7 +72,7 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
|
|||
<TabsContent value="members">
|
||||
<MembersList
|
||||
members={members}
|
||||
currentUserId={session.user.id}
|
||||
currentUserId={me.id}
|
||||
currentUserRole={userRoleInOrg}
|
||||
orgName={org.name}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ import { isServiceError } from "@/lib/utils";
|
|||
import Link from "next/link";
|
||||
import { ArrowLeftIcon } from "@radix-ui/react-icons";
|
||||
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||
import { env } from "@/env.mjs";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
|
||||
export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) {
|
||||
if (!IS_BILLING_ENABLED) {
|
||||
redirect(`/${domain}`);
|
||||
}
|
||||
|
||||
const subscription = await fetchSubscription(domain);
|
||||
if (!subscription) {
|
||||
|
|
@ -52,9 +57,11 @@ export default async function Upgrade({ params: { domain } }: { params: { domain
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<OrgSelector
|
||||
domain={domain}
|
||||
/>
|
||||
{env.SOURCEBOT_TENANCY_MODE === 'multi' && (
|
||||
<OrgSelector
|
||||
domain={domain}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mt-12">
|
||||
<TeamUpgradeCard
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -22,5 +22,5 @@ const getRepos = (domain: string) =>
|
|||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const response = await listRepositories(orgId);
|
||||
return response;
|
||||
})
|
||||
);
|
||||
}
|
||||
), /* allowSingleTenantUnauthedAccess */ true);
|
||||
|
|
@ -30,4 +30,5 @@ const postSearch = (request: SearchRequest, domain: string) =>
|
|||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const response = await search(request, orgId);
|
||||
return response;
|
||||
}))
|
||||
}
|
||||
), /* allowSingleTenantUnauthedAccess */ true);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
148
packages/web/src/app/error.tsx
Normal file
148
packages/web/src/app/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `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. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
57
packages/web/src/initialize.ts
Normal file
57
packages/web/src/initialize.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
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';
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import { StatusCodes } from "http-status-codes";
|
||||
import { ErrorCode } from "./errorCodes";
|
||||
import { ZodError } from "zod";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
export interface ServiceError {
|
||||
statusCode: StatusCodes;
|
||||
errorCode: ErrorCode;
|
||||
message: string;
|
||||
export const serviceErrorSchema = z.object({
|
||||
statusCode: z.number(),
|
||||
errorCode: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export type ServiceError = z.infer<typeof serviceErrorSchema>;
|
||||
|
||||
/**
|
||||
* Useful for throwing errors and handling them in error boundaries.
|
||||
*/
|
||||
export class ServiceErrorException extends Error {
|
||||
constructor(public readonly serviceError: ServiceError) {
|
||||
super(JSON.stringify(serviceError));
|
||||
}
|
||||
}
|
||||
|
||||
export const serviceErrorResponse = ({ statusCode, errorCode, message }: ServiceError) => {
|
||||
|
|
@ -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.",
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof getVersionResponseSchema>;
|
|||
export enum SearchQueryParams {
|
||||
query = "query",
|
||||
maxMatchDisplayCount = "maxMatchDisplayCount",
|
||||
}
|
||||
}
|
||||
|
||||
export type TenancyMode = z.infer<typeof tenancyModeSchema>;
|
||||
40
packages/web/src/middleware.ts
Normal file
40
packages/web/src/middleware.ts
Normal 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).*)'
|
||||
],
|
||||
}
|
||||
Loading…
Reference in a new issue