mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-13 12:55:19 +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=
|
# CONFIG_MAX_REPOS_NO_TOKEN=
|
||||||
# SOURCEBOT_ROOT_DOMAIN=
|
# SOURCEBOT_ROOT_DOMAIN=
|
||||||
# NODE_ENV=
|
# NODE_ENV=
|
||||||
|
# SOURCEBOT_TENANCY_MODE=mutli
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
const response = await listRepositories(orgId);
|
const response = await listRepositories(orgId);
|
||||||
return response;
|
return response;
|
||||||
})
|
}
|
||||||
);
|
), /* allowSingleTenantUnauthedAccess */ true);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
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";
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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.
|
||||||
|
|
|
||||||
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';
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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>;
|
||||||
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