Domain support (#188)

This commit is contained in:
Brendan Kellam 2025-02-12 13:51:44 -08:00 committed by GitHub
parent 568ded8dd2
commit 34c9c1d9a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 2103 additions and 1976 deletions

View file

@ -45,10 +45,6 @@ ARG NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED=BAKED_NEXT_PUBLIC_SOURCEBOT_TELEMET
ARG NEXT_PUBLIC_SOURCEBOT_VERSION=BAKED_NEXT_PUBLIC_SOURCEBOT_VERSION
ENV NEXT_PUBLIC_POSTHOG_PAPIK=BAKED_NEXT_PUBLIC_POSTHOG_PAPIK
# We declare SOURCEBOT_ENCRYPTION_KEY here since it's read during the build stage, since it's read in a server side component
ARG SOURCEBOT_ENCRYPTION_KEY
ENV SOURCEBOT_ENCRYPTION_KEY=$SOURCEBOT_ENCRYPTION_KEY
# @nocheckin: This was interfering with the the `matcher` regex in middleware.ts,
# causing regular expressions parsing errors when making a request. It's unclear
# why exactly this was happening, but it's likely due to a bad replacement happening
@ -79,21 +75,16 @@ WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV DATA_DIR=/data
ENV CONFIG_PATH=$DATA_DIR/config.json
ENV DATA_CACHE_DIR=$DATA_DIR/.sourcebot
ENV DB_DATA_DIR=$DATA_CACHE_DIR/db
ENV DB_NAME=sourcebot
ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot"
ENV SRC_TENANT_ENFORCEMENT_MODE=strict
ARG SOURCEBOT_VERSION=unknown
ENV SOURCEBOT_VERSION=$SOURCEBOT_VERSION
RUN echo "Sourcebot Version: $SOURCEBOT_VERSION"
# Redeclare SOURCEBOT_ENCRYPTION_KEY so that we have it in the runner
ARG SOURCEBOT_ENCRYPTION_KEY
ENV SOURCEBOT_TENANT_MODE=single
# Valid values are: debug, info, warn, error
ENV SOURCEBOT_LOG_LEVEL=info

View file

@ -86,27 +86,6 @@ fi
echo "{\"version\": \"$SOURCEBOT_VERSION\", \"install_id\": \"$SOURCEBOT_INSTALL_ID\"}" > "$FIRST_RUN_FILE"
if [ ! -z "$SOURCEBOT_TENANT_MODE" ]; then
echo -e "\e[34m[Info] Sourcebot tenant mode: $SOURCEBOT_TENANT_MODE\e[0m"
else
echo -e "\e[31m[Error] SOURCEBOT_TENANT_MODE is not set.\e[0m"
exit 1
fi
# If we're in single tenant mode, fallback to sample config if a config does not exist
if [ "$SOURCEBOT_TENANT_MODE" = "single" ]; then
if echo "$CONFIG_PATH" | grep -qE '^https?://'; then
if ! curl --output /dev/null --silent --head --fail "$CONFIG_PATH"; then
echo -e "\e[33m[Warning] Remote config file at '$CONFIG_PATH' not found. Falling back on sample config.\e[0m"
CONFIG_PATH="./default-config.json"
fi
elif [ ! -f "$CONFIG_PATH" ]; then
echo -e "\e[33m[Warning] Config file at '$CONFIG_PATH' not found. Falling back on sample config.\e[0m"
CONFIG_PATH="./default-config.json"
fi
echo -e "\e[34m[Info] Using config file at: '$CONFIG_PATH'.\e[0m"
fi
# Update NextJs public env variables w/o requiring a rebuild.
# @see: https://phase.dev/blog/nextjs-public-runtime-variables/

View file

@ -6,12 +6,8 @@
"scripts": {
"build": "yarn workspaces run build",
"test": "yarn workspaces run test",
"dev": "cross-env SOURCEBOT_TENANT_MODE=single npm-run-all --print-label dev:start",
"dev:mt": "cross-env SOURCEBOT_TENANT_MODE=multi npm-run-all --print-label dev:start:mt",
"dev:start": "yarn workspace @sourcebot/db prisma:migrate:dev && cross-env npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web",
"dev:start:mt": "yarn workspace @sourcebot/db prisma:migrate:dev && cross-env npm-run-all --print-label --parallel dev:zoekt:mt dev:backend dev:web",
"dev:zoekt": "export PATH=\"$PWD/bin:$PATH\" && export SRC_TENANT_ENFORCEMENT_MODE=none && zoekt-webserver -index .sourcebot/index -rpc",
"dev:zoekt:mt": "export PATH=\"$PWD/bin:$PATH\" && export SRC_TENANT_ENFORCEMENT_MODE=strict && zoekt-webserver -index .sourcebot/index -rpc",
"dev": "yarn workspace @sourcebot/db prisma:migrate:dev && cross-env npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web",
"dev:zoekt": "export PATH=\"$PWD/bin:$PATH\" && export SRC_TENANT_ENFORCEMENT_MODE=strict && zoekt-webserver -index .sourcebot/index -rpc",
"dev:backend": "yarn workspace @sourcebot/backend dev:watch",
"dev:web": "yarn workspace @sourcebot/web dev"
},

View file

@ -5,7 +5,7 @@
"main": "index.js",
"type": "module",
"scripts": {
"dev:watch": "tsc-watch --preserveWatchOutput --onSuccess \"yarn dev --configPath ../../config.json --cacheDir ../../.sourcebot\"",
"dev:watch": "tsc-watch --preserveWatchOutput --onSuccess \"yarn dev --cacheDir ../../.sourcebot\"",
"dev": "export PATH=\"$PWD/../../bin:$PATH\" && export CTAGS_COMMAND=ctags && node ./dist/index.js",
"build": "tsc",
"test": "vitest --config ./vitest.config.ts"

View file

@ -2,7 +2,7 @@ import dotenv from 'dotenv';
export const getEnv = (env: string | undefined, defaultValue?: string, required?: boolean) => {
if (required && !env && !defaultValue) {
throw new Error(`Missing required environment variable`);
throw new Error(`Missing required environment variable: ${env}`);
}
return env ?? defaultValue;
@ -20,7 +20,6 @@ dotenv.config({
});
export const SOURCEBOT_TENANT_MODE = getEnv(process.env.SOURCEBOT_TENANT_MODE, undefined, true);
export const SOURCEBOT_LOG_LEVEL = getEnv(process.env.SOURCEBOT_LOG_LEVEL, 'info')!;
export const SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.env.SOURCEBOT_TELEMETRY_DISABLED, false)!;
export const SOURCEBOT_INSTALL_ID = getEnv(process.env.SOURCEBOT_INSTALL_ID, 'unknown')!;

View file

@ -2,11 +2,9 @@ import { ArgumentParser } from "argparse";
import { existsSync } from 'fs';
import { mkdir } from 'fs/promises';
import path from 'path';
import { isRemotePath } from "./utils.js";
import { AppContext } from "./types.js";
import { main } from "./main.js"
import { PrismaClient } from "@sourcebot/db";
import { SOURCEBOT_TENANT_MODE } from "./environment.js";
const parser = new ArgumentParser({
@ -18,22 +16,12 @@ type Arguments = {
cacheDir: string;
}
parser.add_argument("--configPath", {
help: "Path to config file",
required: SOURCEBOT_TENANT_MODE === "single",
});
parser.add_argument("--cacheDir", {
help: "Path to .sourcebot cache directory",
required: true,
});
const args = parser.parse_args() as Arguments;
if (SOURCEBOT_TENANT_MODE === "single" && !isRemotePath(args.configPath) && !existsSync(args.configPath)) {
console.error(`Config file ${args.configPath} does not exist, and is required in single tenant mode`);
process.exit(1);
}
const cacheDir = args.cacheDir;
const reposPath = path.join(cacheDir, 'repos');
const indexPath = path.join(cacheDir, 'index');

View file

@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[domain]` on the table `Org` will be added. If there are existing duplicate values, this will fail.
- Added the required column `domain` to the `Org` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Org" ADD COLUMN "domain" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Org_domain_key" ON "Org"("domain");

View file

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `activeOrgId` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "activeOrgId";

View file

@ -107,6 +107,7 @@ model Invite {
model Org {
id Int @id @default(autoincrement())
name String
domain String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members UserToOrg[]
@ -161,7 +162,6 @@ model User {
image String?
accounts Account[]
orgs UserToOrg[]
activeOrgId Int?
/// List of pending invites that the user has created
invites Invite[]

View file

@ -147,10 +147,6 @@ const schema = {
]
]
},
"tenantId": {
"type": "number",
"description": "@nocheckin"
},
"exclude": {
"type": "object",
"properties": {

View file

@ -95,10 +95,6 @@ export interface GitHubConfig {
* @minItems 1
*/
topics?: string[];
/**
* @nocheckin
*/
tenantId?: number;
exclude?: {
/**
* Exclude forked repositories from syncing.

View file

@ -104,6 +104,7 @@
"next-themes": "^0.3.0",
"posthog-js": "^1.161.5",
"pretty-bytes": "^6.1.1",
"psl": "^1.15.0",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.53.0",
@ -120,6 +121,7 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/psl": "^1.1.3",
"@types/react": "^18",
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^8.3.0",

View file

@ -1,7 +1,7 @@
'use server';
import Ajv from "ajv";
import { auth, getCurrentUserOrg } from "./auth";
import { auth } from "./auth";
import { notAuthenticated, notFound, ServiceError, unexpectedError } from "@/lib/serviceError";
import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes";
@ -12,18 +12,95 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { encrypt } from "@sourcebot/crypto"
import { getConnection } from "./data/connection";
import { Prisma, Invite } from "@sourcebot/db";
import { ConnectionSyncStatus, Invite, Prisma } from "@sourcebot/db";
import { Session } from "next-auth";
const ajv = new Ajv({
validateFormats: false,
});
export const createSecret = async (key: string, value: string): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
export const withAuth = async <T>(fn: (session: Session) => Promise<T>) => {
const session = await auth();
if (!session) {
return notAuthenticated();
}
return fn(session);
}
export const withOrgMembership = async <T>(session: Session, domain: string, fn: (orgId: number) => Promise<T>) => {
const org = await prisma.org.findUnique({
where: {
domain,
},
});
if (!org) {
return notFound();
}
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: session.user.id,
orgId: org.id,
}
},
});
if (!membership) {
return notFound();
}
return fn(org.id);
}
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,
}
}
}
}
}
});
return {
id: org.id,
}
});
export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const secrets = await prisma.secret.findMany({
where: {
orgId,
},
select: {
key: true,
createdAt: true
}
});
return secrets.map((secret) => ({
key: secret.key,
createdAt: secret.createdAt,
}));
}));
export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
try {
const encrypted = encrypt(value);
await prisma.secret.create({
@ -41,36 +118,11 @@ export const createSecret = async (key: string, value: string): Promise<{ succes
return {
success: true,
}
}
export const getSecrets = async (): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
const secrets = await prisma.secret.findMany({
where: {
orgId,
},
select: {
key: true,
createdAt: true
}
});
return secrets.map((secret) => ({
key: secret.key,
createdAt: secret.createdAt,
}));
}
export const deleteSecret = async (key: string): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
await prisma.secret.delete({
where: {
orgId_key: {
@ -83,74 +135,42 @@ export const deleteSecret = async (key: string): Promise<{ success: boolean } |
return {
success: true,
}
}
}));
export const createOrg = async (name: string): Promise<{ id: number } | ServiceError> => {
const session = await auth();
if (!session) {
return notAuthenticated();
}
// Create the org
const org = await prisma.org.create({
data: {
name,
members: {
create: {
userId: session.user.id,
role: "OWNER",
},
},
}
});
return {
id: org.id,
}
}
export const switchActiveOrg = async (orgId: number): Promise<{ id: number } | ServiceError> => {
const session = await auth();
if (!session) {
return notAuthenticated();
}
// Check to see if the user is a member of the org
// @todo: refactor this into a shared function
const membership = await prisma.userToOrg.findUnique({
export const getConnections = async (domain: string): Promise<
{
id: number,
name: string,
syncStatus: ConnectionSyncStatus,
connectionType: string,
updatedAt: Date,
syncedAt?: Date
}[] | ServiceError
> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const connections = await prisma.connection.findMany({
where: {
orgId_userId: {
userId: session.user.id,
orgId,
}
},
});
if (!membership) {
return notFound();
}
// Update the user's active org
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
activeOrgId: orgId,
}
});
return connections.map((connection) => ({
id: connection.id,
name: connection.name,
syncStatus: connection.syncStatus,
connectionType: connection.connectionType,
updatedAt: connection.updatedAt,
syncedAt: connection.syncedAt ?? undefined,
}));
})
);
return {
id: orgId,
}
}
export const createConnection = async (name: string, type: string, connectionConfig: string): Promise<{ id: number } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const parsedConfig = parseConnectionConfig(type, connectionConfig);
if (isServiceError(parsedConfig)) {
return parsedConfig;
@ -168,14 +188,11 @@ export const createConnection = async (name: string, type: string, connectionCon
return {
id: connection.id,
}
}
export const updateConnectionDisplayName = async (connectionId: number, name: string): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
}));
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
@ -194,14 +211,11 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st
return {
success: true,
}
}
export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
}));
export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
@ -236,14 +250,11 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number
return {
success: true,
}
}
export const deleteConnection = async (connectionId: number): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
}));
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
@ -259,7 +270,59 @@ export const deleteConnection = async (connectionId: number): Promise<{ success:
return {
success: true,
}
}
}));
export const createInvite = async (email: string, userId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
console.log("Creating invite for", email, userId, orgId);
try {
await prisma.invite.create({
data: {
recipientEmail: email,
hostUserId: userId,
orgId,
}
});
} catch (error) {
console.error("Failed to create invite:", error);
return unexpectedError("Failed to create invite");
}
return {
success: true,
}
})
);
export const redeemInvite = async (invite: Invite, userId: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async () => {
try {
await prisma.$transaction(async (tx) => {
await tx.userToOrg.create({
data: {
userId,
orgId: invite.orgId,
role: "MEMBER",
}
});
await tx.invite.delete({
where: {
id: invite.id,
}
});
});
return {
success: true,
}
} catch (error) {
console.error("Failed to redeem invite:", error);
return unexpectedError("Failed to redeem invite");
}
});
const parseConnectionConfig = (connectionType: string, config: string) => {
let parsedConfig: ConnectionConfig;
@ -301,58 +364,3 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
return parsedConfig;
}
export const createInvite = async (email: string, userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => {
console.log("Creating invite for", email, userId, orgId);
try {
await prisma.invite.create({
data: {
recipientEmail: email,
hostUserId: userId,
orgId,
}
});
} catch (error) {
console.error("Failed to create invite:", error);
return unexpectedError("Failed to create invite");
}
return {
success: true,
}
}
export const redeemInvite = async (invite: Invite, userId: string): Promise<{ orgId: number } | ServiceError> => {
try {
await prisma.userToOrg.create({
data: {
userId,
orgId: invite.orgId,
role: "MEMBER",
}
});
await prisma.user.update({
where: {
id: userId,
},
data: {
activeOrgId: invite.orgId,
}
});
await prisma.invite.delete({
where: {
id: invite.id,
}
});
return {
orgId: invite.orgId,
}
} catch (error) {
console.error("Failed to redeem invite:", error);
return unexpectedError("Failed to redeem invite");
}
}

View file

@ -1,17 +1,18 @@
import { FileHeader } from "@/app/components/fireHeader";
import { TopBar } from "@/app/components/topBar";
import { FileHeader } from "@/app/[domain]/components/fireHeader";
import { TopBar } from "@/app/[domain]/components/topBar";
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/components/pageNotFound";
import { PageNotFound } from "@/app/[domain]/components/pageNotFound";
import { ErrorCode } from "@/lib/errorCodes";
import { LuFileX2, LuBookX } from "react-icons/lu";
import { getCurrentUserOrg } from "@/auth";
import { getOrgFromDomain } from "@/data/org";
interface BrowsePageProps {
params: {
path: string[];
domain: string;
};
}
@ -45,18 +46,14 @@ export default async function BrowsePage({
}
})();
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return (
<>
Error: {orgId.message}
</>
)
const org = await getOrgFromDomain(params.domain);
if (!org) {
return <PageNotFound />
}
// @todo (bkellam) : We should probably have a endpoint to fetch repository metadata
// given it's name or id.
const reposResponse = await listRepositories(orgId);
const reposResponse = await listRepositories(org.id);
if (isServiceError(reposResponse)) {
// @todo : proper error handling
return (
@ -81,6 +78,7 @@ export default async function BrowsePage({
<div className='sticky top-0 left-0 right-0 z-10'>
<TopBar
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
domain={params.domain}
/>
<Separator />
{repo && (
@ -108,7 +106,7 @@ export default async function BrowsePage({
path={path}
repoName={repoName}
revisionName={revisionName ?? 'HEAD'}
orgId={orgId}
orgId={org.id}
/>
)}
</div>

View file

@ -8,7 +8,7 @@ import { autoPlacement, computePosition, offset, shift, VirtualElement } from "@
import { Link2Icon } from "@radix-ui/react-icons";
import { EditorView, SelectionRange } from "@uiw/react-codemirror";
import { useCallback, useEffect, useRef } from "react";
import { resolveServerPath } from "../api/(client)/client";
import { resolveServerPath } from "../../api/(client)/client";
interface ContextMenuProps {
view: EditorView;

View file

@ -3,8 +3,8 @@ import { NavigationMenu as NavigationMenuBase, NavigationMenuItem, NavigationMen
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import Image from "next/image";
import logoDark from "../../../public/sb_logo_dark_small.png";
import logoLight from "../../../public/sb_logo_light_small.png";
import logoDark from "@/public/sb_logo_dark_small.png";
import logoLight from "@/public/sb_logo_light_small.png";
import { SettingsDropdown } from "./settingsDropdown";
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
import { redirect } from "next/navigation";
@ -13,14 +13,19 @@ import { OrgSelector } from "./orgSelector";
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
export const NavigationMenu = async () => {
interface NavigationMenuProps {
domain: string;
}
export const NavigationMenu = async ({
domain,
}: NavigationMenuProps) => {
return (
<div className="flex flex-col w-screen h-fit">
<div className="flex flex-row justify-between items-center py-1.5 px-3">
<div className="flex flex-row items-center">
<Link
href="/"
href={`/${domain}`}
className="mr-3 cursor-pointer"
>
<Image
@ -37,41 +42,43 @@ export const NavigationMenu = async () => {
/>
</Link>
<OrgSelector />
<OrgSelector
domain={domain}
/>
<Separator orientation="vertical" className="h-6 mx-2" />
<NavigationMenuBase>
<NavigationMenuList>
<NavigationMenuItem>
<Link href="/" legacyBehavior passHref>
<Link href={`/${domain}`} legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Search
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/repos" legacyBehavior passHref>
<Link href={`/${domain}/repos`} legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Repositories
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/secrets" legacyBehavior passHref>
<Link href={`/${domain}/secrets`} legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Secrets
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/connections" legacyBehavior passHref>
<Link href={`/${domain}/connections`} legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Connections
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/settings" legacyBehavior passHref>
<Link href={`/${domain}/settings`} legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Settings
</NavigationMenuLink>

View file

@ -1,20 +1,27 @@
import { auth } from "@/auth";
import { getUser, getUserOrgs } from "../../../data/user";
import { getUserOrgs } from "../../../../data/user";
import { OrgSelectorDropdown } from "./orgSelectorDropdown";
import { prisma } from "@/prisma";
export const OrgSelector = async () => {
interface OrgSelectorProps {
domain: string;
}
export const OrgSelector = async ({
domain,
}: OrgSelectorProps) => {
const session = await auth();
if (!session) {
return null;
}
const user = await getUser(session.user.id);
if (!user) {
return null;
}
const orgs = await getUserOrgs(session.user.id);
const activeOrg = orgs.find((org) => org.id === user.activeOrgId);
const activeOrg = await prisma.org.findUnique({
where: {
domain,
}
});
if (!activeOrg) {
return null;
}
@ -24,6 +31,7 @@ export const OrgSelector = async () => {
orgs={orgs.map((org) => ({
name: org.name,
id: org.id,
domain: org.domain,
}))}
activeOrgId={activeOrg.id}
/>

View file

@ -1,20 +1,18 @@
'use client';
import { createOrg, switchActiveOrg } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { isServiceError } from "@/lib/utils";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { OrgCreationDialog } from "./orgCreationDialog";
import { OrgIcon } from "./orgIcon";
interface OrgSelectorDropdownProps {
orgs: {
name: string,
domain: string,
id: number,
}[],
activeOrgId: number,
@ -26,7 +24,6 @@ export const OrgSelectorDropdown = ({
}: OrgSelectorDropdownProps) => {
const [searchFilter, setSearchFilter] = useState("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isCreateOrgDialogOpen, setIsCreateOrgDialogOpen] = useState(false);
const { toast } = useToast();
const router = useRouter();
@ -39,61 +36,13 @@ export const OrgSelectorDropdown = ({
];
}, [_orgs, activeOrg, activeOrgId]);
const onSwitchOrg = useCallback((orgId: number, orgName: string) => {
switchActiveOrg(orgId)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to switch organization. Reason: ${response.message}`,
});
} else {
const onSwitchOrg = useCallback((domain: string, orgName: string) => {
router.push(`/${domain}`);
toast({
description: `✅ Switched to ${orgName}`,
});
}
setIsDropdownOpen(false);
// Necessary to refresh the server component.
router.refresh();
});
}, [router, toast]);
const onCreateOrg = useCallback((name: string) => {
createOrg(name)
.then((response) => {
if (isServiceError(response)) {
throw response;
}
return switchActiveOrg(response.id);
})
.then((response) => {
if (isServiceError(response)) {
throw response;
}
toast({
description: `✅ Organization '${name}' created successfully.`,
});
setIsDropdownOpen(false);
// Necessary to refresh the server component.
router.refresh();
})
.catch((error) => {
if (isServiceError(error)) {
toast({
description: `❌ Failed to create organization. Reason: ${error.message}`,
});
}
})
.finally(() => {
setIsCreateOrgDialogOpen(false);
});
}, [router, toast]);
return (
/*
We need to set `modal=false` to fix a issue with having a dialog menu inside of
@ -144,7 +93,7 @@ export const OrgSelectorDropdown = ({
// Need to include org id to handle duplicates.
value={`${org.name}-${org.id}`}
className="w-full justify-between py-3 font-medium cursor-pointer"
onSelect={() => onSwitchOrg(org.id, org.name)}
onSelect={() => onSwitchOrg(org.domain, org.name)}
>
<div className="flex flex-row gap-1.5 items-center">
<OrgIcon />
@ -159,16 +108,6 @@ export const OrgSelectorDropdown = ({
</CommandList>
</Command>
</DropdownMenuGroup>
{searchFilter.length === 0 && (
<DropdownMenuGroup>
<DropdownMenuSeparator />
<OrgCreationDialog
isOpen={isCreateOrgDialogOpen}
onOpenChange={setIsCreateOrgDialogOpen}
onSubmit={({ name }) => onCreateOrg(name)}
/>
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
);

View file

@ -42,6 +42,7 @@ import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Toggle } from "@/components/ui/toggle";
import { useDomain } from "@/hooks/useDomain";
interface SearchBarProps {
className?: string;
@ -92,6 +93,7 @@ export const SearchBar = ({
autoFocus,
}: SearchBarProps) => {
const router = useRouter();
const domain = useDomain();
const tailwind = useTailwind();
const suggestionBoxRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<ReactCodeMirrorRef>(null);
@ -202,11 +204,11 @@ export const SearchBar = ({
setIsSuggestionsEnabled(false);
setIsHistorySearchEnabled(false);
const url = createPathWithQueryParams('/search',
const url = createPathWithQueryParams(`/${domain}/search`,
[SearchQueryParams.query, query],
);
router.push(url);
}, [router]);
}, [domain, router]);
return (
<div

View file

@ -19,6 +19,7 @@ import {
} from "react-icons/vsc";
import { useSearchHistory } from "@/hooks/useSearchHistory";
import { getDisplayTime } from "@/lib/utils";
import { useDomain } from "@/hooks/useDomain";
interface Props {
@ -33,9 +34,10 @@ export const useSuggestionsData = ({
suggestionMode,
suggestionQuery,
}: Props) => {
const domain = useDomain();
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
queryKey: ["repoSuggestions"],
queryFn: getRepos,
queryFn: () => getRepos(domain),
select: (data): Suggestion[] => {
return data.List.Repos
.map(r => r.Repository)
@ -52,7 +54,7 @@ export const useSuggestionsData = ({
queryFn: () => search({
query: `file:${suggestionQuery}`,
maxMatchDisplayCount: 15,
}),
}, domain),
select: (data): Suggestion[] => {
return data.Result.Files?.map((file) => ({
value: file.FileName
@ -67,7 +69,7 @@ export const useSuggestionsData = ({
queryFn: () => search({
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
maxMatchDisplayCount: 15,
}),
}, domain),
select: (data): Suggestion[] => {
const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []);
if (!symbols) {

View file

@ -86,7 +86,9 @@ export const SettingsDropdown = ({
</div>
<DropdownMenuItem
onClick={() => {
signOut();
signOut({
redirectTo: "/login",
});
}}
>
<LogOut className="mr-2 h-4 w-4" />

View file

@ -7,16 +7,18 @@ import { SettingsDropdown } from "./settingsDropdown";
interface TopBarProps {
defaultSearchQuery?: string;
domain: string;
}
export const TopBar = ({
defaultSearchQuery
defaultSearchQuery,
domain,
}: TopBarProps) => {
return (
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4 bg-background">
<div className="grow flex flex-row gap-4 items-center">
<Link
href="/"
href={`/${domain}`}
className="shrink-0 cursor-pointer"
>
<Image

View file

@ -19,6 +19,7 @@ import { updateConnectionConfigAndScheduleSync } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { isServiceError } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
interface ConfigSettingProps {
@ -61,6 +62,7 @@ function ConfigSettingInternal<T>({
}) {
const { toast } = useToast();
const router = useRouter();
const domain = useDomain();
const formSchema = useMemo(() => {
return z.object({
config: createZodConnectionConfigValidator(schema),
@ -77,7 +79,7 @@ function ConfigSettingInternal<T>({
const [isLoading, setIsLoading] = useState(false);
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
setIsLoading(true);
updateConnectionConfigAndScheduleSync(connectionId, data.config)
updateConnectionConfigAndScheduleSync(connectionId, data.config, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
@ -94,7 +96,7 @@ function ConfigSettingInternal<T>({
.finally(() => {
setIsLoading(false);
})
}, [connectionId, router, toast]);
}, [connectionId, domain, router, toast]);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">

View file

@ -18,6 +18,7 @@ import { Loader2 } from "lucide-react";
import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
interface DeleteConnectionSettingProps {
connectionId: number;
@ -28,13 +29,14 @@ export const DeleteConnectionSetting = ({
}: DeleteConnectionSettingProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const domain = useDomain();
const { toast } = useToast();
const router = useRouter();
const handleDelete = useCallback(() => {
setIsDialogOpen(false);
setIsLoading(true);
deleteConnection(connectionId)
deleteConnection(connectionId, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
@ -44,14 +46,14 @@ export const DeleteConnectionSetting = ({
toast({
description: `✅ Connection deleted successfully.`
});
router.replace("/connections");
router.replace(`/${domain}/connections`);
router.refresh();
}
})
.finally(() => {
setIsLoading(false);
});
}, [connectionId]);
}, [connectionId, domain, router, toast]);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">

View file

@ -5,6 +5,7 @@ import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
@ -28,6 +29,7 @@ export const DisplayNameSetting = ({
}: DisplayNameSettingProps) => {
const { toast } = useToast();
const router = useRouter();
const domain = useDomain();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@ -38,7 +40,7 @@ export const DisplayNameSetting = ({
const [isLoading, setIsLoading] = useState(false);
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
setIsLoading(true);
updateConnectionDisplayName(connectionId, data.name)
updateConnectionDisplayName(connectionId, data.name, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
@ -53,7 +55,7 @@ export const DisplayNameSetting = ({
}).finally(() => {
setIsLoading(false);
});
}, [connectionId, router, toast]);
}, [connectionId, domain, router, toast]);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">

View file

@ -1,5 +1,4 @@
import { NotFound } from "@/app/components/notFound";
import { getCurrentUserOrg } from "@/auth";
import { NotFound } from "@/app/[domain]/components/notFound";
import {
Breadcrumb,
BreadcrumbItem,
@ -12,17 +11,19 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { TabSwitcher } from "@/components/ui/tab-switcher";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { getConnection, getLinkedRepos } from "@/data/connection";
import { isServiceError } from "@/lib/utils";
import { ConnectionIcon } from "../components/connectionIcon";
import { Header } from "../../components/header";
import { ConfigSetting } from "./components/configSetting";
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting";
import { DisplayNameSetting } from "./components/displayNameSetting";
import { RepoListItem } from "./components/repoListItem";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "../../components/pageNotFound";
interface ConnectionManagementPageProps {
params: {
id: string;
domain: string;
},
searchParams: {
tab?: string;
@ -33,13 +34,9 @@ export default async function ConnectionManagementPage({
params,
searchParams,
}: ConnectionManagementPageProps) {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return (
<>
Error: {orgId.message}
</>
)
const org = await getOrgFromDomain(params.domain);
if (!org) {
return <PageNotFound />
}
const connectionId = Number(params.id);
@ -52,7 +49,7 @@ export default async function ConnectionManagementPage({
)
}
const connection = await getConnection(Number(params.id), orgId);
const connection = await getConnection(Number(params.id), org.id);
if (!connection) {
return (
<NotFound
@ -62,7 +59,7 @@ export default async function ConnectionManagementPage({
)
}
const linkedRepos = await getLinkedRepos(connectionId, orgId);
const linkedRepos = await getLinkedRepos(connectionId, org.id);
const currentTab = searchParams.tab || "overview";
@ -78,7 +75,7 @@ export default async function ConnectionManagementPage({
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/connections">Connections</BreadcrumbLink>
<BreadcrumbLink href={`/${params.domain}/connections`}>Connections</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>

View file

@ -53,7 +53,7 @@ export const ConnectionListItem = ({
}, [status]);
return (
<Link href={`/connections/${id}`}>
<Link href={`connections/${id}`}>
<div
className="flex flex-row justify-between items-center border p-4 rounded-lg cursor-pointer bg-background"
>

View file

@ -1,11 +1,18 @@
import { Connection } from "@sourcebot/db"
import { ConnectionListItem } from "./connectionListItem";
import { cn } from "@/lib/utils";
import { InfoCircledIcon } from "@radix-ui/react-icons";
import { ConnectionSyncStatus } from "@sourcebot/db";
interface ConnectionListProps {
connections: Connection[];
connections: {
id: number,
name: string,
connectionType: string,
syncStatus: ConnectionSyncStatus,
updatedAt: Date,
syncedAt?: Date
}[];
className?: string;
}

View file

@ -78,7 +78,7 @@ const Card = ({
return (
<Link
className="flex flex-row justify-between items-center cursor-pointer p-2"
href={`/connections/new/${type}`}
href={`connections/new/${type}`}
>
<div className="flex flex-row items-center gap-2">
{Icon}

View file

@ -2,13 +2,15 @@ import { NavigationMenu } from "../components/navigationMenu";
export default function Layout({
children,
params: { domain },
}: Readonly<{
children: React.ReactNode;
params: { domain: string };
}>) {
return (
<div className="min-h-screen flex flex-col">
<NavigationMenu />
<NavigationMenu domain={domain} />
<main className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative">
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
</main>

View file

@ -2,8 +2,8 @@
'use client';
import { createConnection } from "@/actions";
import { ConnectionIcon } from "@/app/connections/components/connectionIcon";
import { createZodConnectionConfigValidator } from "@/app/connections/utils";
import { ConnectionIcon } from "@/app/[domain]/connections/components/connectionIcon";
import { createZodConnectionConfigValidator } from "@/app/[domain]/connections/utils";
import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
@ -16,6 +16,7 @@ import { useCallback, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ConfigEditor, QuickActionFn } from "../../../components/configEditor";
import { useDomain } from "@/hooks/useDomain";
interface ConnectionCreationForm<T> {
type: 'github' | 'gitlab';
@ -41,6 +42,7 @@ export default function ConnectionCreationForm<T>({
const { toast } = useToast();
const router = useRouter();
const domain = useDomain();
const formSchema = useMemo(() => {
return z.object({
@ -55,7 +57,7 @@ export default function ConnectionCreationForm<T>({
});
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
createConnection(data.name, type, data.config)
createConnection(data.name, type, data.config, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
@ -65,11 +67,11 @@ export default function ConnectionCreationForm<T>({
toast({
description: `✅ Connection created successfully.`
});
router.push('/connections');
router.push(`/${domain}/connections`);
router.refresh();
}
});
}, [router, toast, type]);
}, [domain, router, toast, type]);
return (
<div className="flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6">

View file

@ -1,27 +1,16 @@
import { auth } from "@/auth";
import { getUser } from "@/data/user";
import { prisma } from "@/prisma";
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";
export default async function ConnectionsPage() {
const session = await auth();
if (!session) {
return null;
export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) {
const connections = await getConnections(domain);
if (isServiceError(connections)) {
return <NotFoundPage />;
}
const user = await getUser(session.user.id);
if (!user || !user.activeOrgId) {
return null;
}
const connections = await prisma.connection.findMany({
where: {
orgId: user.activeOrgId,
}
});
return (
<div>
<Header>

View file

@ -0,0 +1,42 @@
import { prisma } from "@/prisma";
import { PageNotFound } from "./components/pageNotFound";
import { auth } from "@/auth";
import { getOrgFromDomain } from "@/data/org";
interface LayoutProps {
children: React.ReactNode,
params: { domain: string }
}
export default async function Layout({
children,
params: { domain },
}: LayoutProps) {
const org = await getOrgFromDomain(domain);
if (!org) {
return <PageNotFound />
}
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 (!membership) {
return <PageNotFound />
}
return children;
}

View file

@ -0,0 +1,200 @@
import { listRepositories } from "@/lib/server/searchService";
import { isServiceError } from "@/lib/utils";
import Image from "next/image";
import { Suspense } from "react";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
import { NavigationMenu } from "./components/navigationMenu";
import { RepositoryCarousel } from "./components/repositoryCarousel";
import { SearchBar } from "./components/searchBar";
import { Separator } from "@/components/ui/separator";
import { SymbolIcon } from "@radix-ui/react-icons";
import { UpgradeToast } from "./components/upgradeToast";
import Link from "next/link";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "./components/pageNotFound";
export default async function Home({ params: { domain } }: { params: { domain: string } }) {
const org = await getOrgFromDomain(domain);
if (!org) {
return <PageNotFound />
}
return (
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<NavigationMenu
domain={domain}
/>
<UpgradeToast />
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<SearchBar
autoFocus={true}
className="mt-4 w-full max-w-[800px]"
/>
<div className="mt-8">
<Suspense fallback={<div>...</div>}>
<RepositoryList
orgId={org.id}
domain={domain}
/>
</Suspense>
</div>
<div className="flex flex-col items-center w-fit gap-6">
<Separator className="mt-5" />
<span className="font-semibold">How to search</span>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<HowToSection
title="Search in files or paths"
>
<QueryExample>
<Query query="test todo">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>
</QueryExample>
<QueryExample>
<Query query={`"exit boot"`}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="TODO case:yes">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>
</QueryExample>
<QueryExample>
<Query query="repo:torvalds/linux test"><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>
</QueryExample>
<QueryExample>
<Query query="rev:HEAD"><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>
</QueryExample>
<QueryExample>
<Query query="sym:main"><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>
</QueryExample>
<QueryExample>
<Query query="content:README"><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
</QueryExample>
</HowToSection>
</div>
</div>
</div>
<footer className="w-full mt-auto py-4 flex flex-row justify-center items-center gap-4">
<Link href="https://sourcebot.dev" className="text-gray-400 text-sm hover:underline">About</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="https://github.com/sourcebot-dev/sourcebot/issues/new" className="text-gray-400 text-sm hover:underline">Support</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="mailto:team@sourcebot.dev" className="text-gray-400 text-sm hover:underline">Contact Us</Link>
</footer>
</div>
)
}
const RepositoryList = async ({ orgId, domain }: { orgId: number, domain: string }) => {
const _repos = await listRepositories(orgId);
if (isServiceError(_repos)) {
return null;
}
const repos = _repos.List.Repos.map((repo) => repo.Repository);
if (repos.length === 0) {
return (
<div className="flex flex-row items-center gap-3">
<SymbolIcon className="h-4 w-4 animate-spin" />
<span className="text-sm">indexing in progress...</span>
</div>
)
}
return (
<div className="flex flex-col items-center gap-3">
<span className="text-sm">
{`Search ${repos.length} `}
<Link
href={`${domain}/repos`}
className="text-blue-500"
>
{repos.length > 1 ? 'repositories' : 'repository'}
</Link>
</span>
<RepositoryCarousel repos={repos} />
</div>
)
}
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
return (
<div className="flex flex-col gap-1">
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
{children}
</div>
)
}
const Highlight = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-highlight">
{children}
</span>
)
}
const QueryExample = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-sm font-mono">
{children}
</span>
)
}
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-gray-500 dark:text-gray-400 ml-3">
{children}
</span>
)
}
const Query = ({ query, children }: { query: string, children: React.ReactNode }) => {
return (
<Link
href={`/search?query=${query}`}
className="cursor-pointer hover:underline"
>
{children}
</Link>
)
}

View file

@ -1,25 +1,21 @@
import { Suspense } from "react";
import { NavigationMenu } from "../components/navigationMenu";
import { RepositoryTable } from "./repositoryTable";
import { getCurrentUserOrg } from "@/auth";
import { isServiceError } from "@/lib/utils";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "../components/pageNotFound";
export default async function ReposPage() {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return (
<>
Error: {orgId.message}
</>
)
export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) {
const org = await getOrgFromDomain(domain);
if (!org) {
return <PageNotFound />
}
return (
<div className="h-screen flex flex-col items-center">
<NavigationMenu />
<NavigationMenu domain={domain} />
<Suspense fallback={<div>Loading...</div>}>
<div className="max-w-[90%]">
<RepositoryTable orgId={ orgId }/>
<RepositoryTable orgId={ org.id }/>
</div>
</Suspense>
</div>

View file

@ -1,6 +1,6 @@
'use client';
import { EditorContextMenu } from "@/app/components/editorContextMenu";
import { EditorContextMenu } from "@/app/[domain]/components/editorContextMenu";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useKeymapExtension } from "@/hooks/useKeymapExtension";

View file

@ -5,6 +5,7 @@ import { base64Decode } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { CodePreview, CodePreviewFile } from "./codePreview";
import { SearchResultFile } from "@/lib/types";
import { useDomain } from "@/hooks/useDomain";
interface CodePreviewPanelProps {
fileMatch?: SearchResultFile;
@ -21,6 +22,7 @@ export const CodePreviewPanel = ({
onSelectedMatchIndexChange,
repoUrlTemplates,
}: CodePreviewPanelProps) => {
const domain = useDomain();
const { data: file } = useQuery({
queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository, fileMatch?.Branches],
@ -37,7 +39,7 @@ export const CodePreviewPanel = ({
fileName: fileMatch.FileName,
repository: fileMatch.Repository,
branch,
})
}, domain)
.then(({ source }) => {
const link = (() => {
const template = repoUrlTemplates[fileMatch.Repository];

View file

@ -1,6 +1,6 @@
'use client';
import { FileHeader } from "@/app/components/fireHeader";
import { FileHeader } from "@/app/[domain]/components/fireHeader";
import { Separator } from "@/components/ui/separator";
import { Repository, SearchResultFile } from "@/lib/types";
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";

View file

@ -16,11 +16,12 @@ import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ImperativePanelHandle } from "react-resizable-panels";
import { getRepos, search } from "../api/(client)/client";
import { getRepos, search } from "../../api/(client)/client";
import { TopBar } from "../components/topBar";
import { CodePreviewPanel } from "./components/codePreviewPanel";
import { FilterPanel } from "./components/filterPanel";
import { SearchResultsPanel } from "./components/searchResultsPanel";
import { useDomain } from "@/hooks/useDomain";
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
@ -42,13 +43,14 @@ const SearchPageInternal = () => {
const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
const { setSearchHistory } = useSearchHistory();
const captureEvent = useCaptureEvent();
const domain = useDomain();
const { data: searchResponse, isLoading } = useQuery({
queryKey: ["search", searchQuery, maxMatchDisplayCount],
queryFn: () => search({
query: searchQuery,
maxMatchDisplayCount,
}),
}, domain),
enabled: searchQuery.length > 0,
refetchOnWindowFocus: false,
});
@ -75,7 +77,7 @@ const SearchPageInternal = () => {
// for easy lookup.
const { data: repoMetadata } = useQuery({
queryKey: ["repos"],
queryFn: () => getRepos(),
queryFn: () => getRepos(domain),
select: (data): Record<string, Repository> =>
data.List.Repos
.map(r => r.Repository)
@ -185,7 +187,10 @@ const SearchPageInternal = () => {
<div className="flex flex-col h-screen overflow-clip">
{/* TopBar */}
<div className="sticky top-0 left-0 right-0 z-10">
<TopBar defaultSearchQuery={searchQuery} />
<TopBar
defaultSearchQuery={searchQuery}
domain={domain}
/>
<Separator />
{!isLoading && (
<div className="bg-accent py-1 px-2 flex flex-row items-center gap-4">

View file

@ -1,21 +1,19 @@
import { NavigationMenu } from "../components/navigationMenu";
import { SecretsTable } from "./secretsTable";
import { getSecrets } from "../../actions"
import { isServiceError } from "@/lib/utils";
import { getSecrets } from "@/actions";
export interface SecretsTableProps {
initialSecrets: { createdAt: Date; key: string; }[];
}
export default async function SecretsPage() {
const secrets = await getSecrets();
export default async function SecretsPage({ params: { domain } }: { params: { domain: string } }) {
const secrets = await getSecrets(domain);
return (
<div className="h-screen flex flex-col items-center">
<NavigationMenu />
<NavigationMenu domain={domain} />
{ !isServiceError(secrets) && (
<div className="max-w-[90%]">
<SecretsTable initialSecrets={secrets} />
<SecretsTable
initialSecrets={secrets}
/>
</div>
)}
</div>

View file

@ -1,6 +1,7 @@
'use client';
import { useEffect, useMemo, useState } from "react";
import { getSecrets, createSecret } from "../../actions"
import { getSecrets, createSecret } from "../../../actions"
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
@ -11,21 +12,26 @@ import { columns, SecretColumnInfo } from "./columns";
import { DataTable } from "@/components/ui/data-table";
import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { deleteSecret } from "../../actions"
import { SecretsTableProps } from "./page";
import { deleteSecret } from "../../../actions"
import { useDomain } from "@/hooks/useDomain";
const formSchema = z.object({
key: z.string().min(2).max(40),
value: z.string().min(2).max(40),
});
interface SecretsTableProps {
initialSecrets: { createdAt: Date; key: string; }[];
}
export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets);
const { toast } = useToast();
const domain = useDomain();
const fetchSecretKeys = async () => {
const keys = await getSecrets();
const keys = await getSecrets(domain);
if ('keys' in keys) {
setSecrets(keys);
} else {
@ -46,7 +52,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
});
const handleCreateSecret = async (values: { key: string, value: string }) => {
const res = await createSecret(values.key, values.value);
const res = await createSecret(values.key, values.value, domain);
if (isServiceError(res)) {
toast({
description: `❌ Failed to create secret`
@ -58,7 +64,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
});
}
const keys = await getSecrets();
const keys = await getSecrets(domain);
if (isServiceError(keys)) {
console.error("Failed to fetch secrets");
} else {
@ -71,7 +77,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
};
const handleDelete = async (key: string) => {
const res = await deleteSecret(key);
const res = await deleteSecret(key, domain);
if (isServiceError(res)) {
toast({
description: `❌ Failed to delete secret`
@ -83,7 +89,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
});
}
const keys = await getSecrets();
const keys = await getSecrets(domain);
if ('keys' in keys) {
setSecrets(keys);
} else {

View file

@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { ColumnDef } from "@tanstack/react-table"
import { resolveServerPath } from "../../api/(client)/client";
import { resolveServerPath } from "@/app/api/(client)/client";
import { createPathWithQueryParams } from "@/lib/utils";
export type InviteColumnInfo = {

View file

@ -6,15 +6,17 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/hooks/use-toast";
import { createInvite } from "../../../actions"
import { createInvite } from "@/actions"
import { isServiceError } from "@/lib/utils";
import { useDomain } from "@/hooks/useDomain";
const formSchema = z.object({
email: z.string().min(2).max(40),
});
export const MemberInviteForm = ({ orgId, userId }: { orgId: number, userId: string }) => {
export const MemberInviteForm = ({ userId }: { userId: string }) => {
const { toast } = useToast();
const domain = useDomain();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -24,7 +26,7 @@ export const MemberInviteForm = ({ orgId, userId }: { orgId: number, userId: str
});
const handleCreateInvite = async (values: { email: string }) => {
const res = await createInvite(values.email, userId, orgId);
const res = await createInvite(values.email, userId, domain);
if (isServiceError(res)) {
toast({
description: `❌ Failed to create invite`

View file

@ -2,13 +2,14 @@ import { NavigationMenu } from "../components/navigationMenu";
export default function Layout({
children,
params: { domain },
}: Readonly<{
children: React.ReactNode;
params: { domain: string };
}>) {
return (
<div className="min-h-screen flex flex-col">
<NavigationMenu />
<NavigationMenu domain={domain} />
<main className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative">
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
</main>

View file

@ -6,7 +6,15 @@ import { MemberTable } from "./components/memberTable";
import { MemberInviteForm } from "./components/memberInviteForm";
import { InviteTable } from "./components/inviteTable";
export default async function SettingsPage() {
interface SettingsPageProps {
params: {
domain: string;
};
}
export default async function SettingsPage({
params: { domain }
}: SettingsPageProps) {
const fetchData = async () => {
const session = await auth();
if (!session) {
@ -14,7 +22,17 @@ export default async function SettingsPage() {
}
const user = await getUser(session.user.id);
if (!user || !user.activeOrgId) {
if (!user) {
return null;
}
const activeOrg = await prisma.org.findUnique({
where: {
domain,
},
});
if (!activeOrg) {
return null;
}
@ -22,14 +40,14 @@ export default async function SettingsPage() {
where: {
orgs: {
some: {
orgId: user.activeOrgId,
orgId: activeOrg.id,
},
},
},
include: {
orgs: {
where: {
orgId: user.activeOrgId,
orgId: activeOrg.id,
},
select: {
role: true,
@ -40,7 +58,7 @@ export default async function SettingsPage() {
const invites = await prisma.invite.findMany({
where: {
orgId: user.activeOrgId,
orgId: activeOrg.id,
},
});
@ -55,7 +73,12 @@ export default async function SettingsPage() {
createdAt: invite.createdAt,
}));
return { user, memberInfo, inviteInfo };
return {
user,
memberInfo,
inviteInfo,
activeOrg,
};
};
const data = await fetchData();
@ -71,7 +94,7 @@ export default async function SettingsPage() {
<h1 className="text-3xl">Settings</h1>
</Header>
<div>
<MemberInviteForm orgId={user.activeOrgId!} userId={user.id} />
<MemberInviteForm userId={user.id} />
<InviteTable initialInvites={inviteInfo} />
<MemberTable initialMembers={memberInfo} />
</div>

View file

@ -5,12 +5,13 @@ import { fileSourceResponseSchema, listRepositoriesResponseSchema, searchRespons
import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types";
import assert from "assert";
export const search = async (body: SearchRequest): Promise<SearchResponse> => {
export const search = async (body: SearchRequest, domain: string): Promise<SearchResponse> => {
const path = resolveServerPath("/api/search");
const result = await fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Org-Domain": domain,
},
body: JSON.stringify(body),
}).then(response => response.json());
@ -18,12 +19,13 @@ export const search = async (body: SearchRequest): Promise<SearchResponse> => {
return searchResponseSchema.parse(result);
}
export const fetchFileSource = async (body: FileSourceRequest): Promise<FileSourceResponse> => {
export const fetchFileSource = async (body: FileSourceRequest, domain: string): Promise<FileSourceResponse> => {
const path = resolveServerPath("/api/source");
const result = await fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Org-Domain": domain,
},
body: JSON.stringify(body),
}).then(response => response.json());
@ -31,12 +33,13 @@ export const fetchFileSource = async (body: FileSourceRequest): Promise<FileSour
return fileSourceResponseSchema.parse(result);
}
export const getRepos = async (): Promise<ListRepositoriesResponse> => {
export const getRepos = async (domain: string): Promise<ListRepositoriesResponse> => {
const path = resolveServerPath("/api/repos");
const result = await fetch(path, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Org-Domain": domain,
},
}).then(response => response.json());

View file

@ -1,16 +1,26 @@
'use server';
import { listRepositories } from "@/lib/server/searchService";
import { getCurrentUserOrg } from "../../../../auth";
import { NextRequest } from "next/server";
import { withAuth, withOrgMembership } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { serviceErrorResponse } from "@/lib/serviceError";
export const GET = async () => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return serviceErrorResponse(orgId);
}
export const GET = async (request: NextRequest) => {
const domain = request.headers.get("X-Org-Domain")!;
const response = await getRepos(domain);
const response = await listRepositories(orgId);
if (isServiceError(response)) {
return serviceErrorResponse(response);
}
return Response.json(response);
}
const getRepos = (domain: string) =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const response = await listRepositories(orgId);
return response;
})
);

View file

@ -2,18 +2,14 @@
import { search } from "@/lib/server/searchService";
import { searchRequestSchema } from "@/lib/schemas";
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
import { getCurrentUserOrg } from "../../../../auth";
import { withAuth, withOrgMembership } from "@/actions";
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { SearchRequest } from "@/lib/types";
export const POST = async (request: NextRequest) => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return serviceErrorResponse(orgId);
}
console.log(`Searching for org ${orgId}`);
const domain = request.headers.get("X-Org-Domain")!;
const body = await request.json();
const parsed = await searchRequestSchema.safeParseAsync(body);
if (!parsed.success) {
@ -22,11 +18,16 @@ export const POST = async (request: NextRequest) => {
);
}
const response = await search(parsed.data, orgId);
const response = await postSearch(parsed.data, domain);
if (isServiceError(response)) {
return serviceErrorResponse(response);
}
return Response.json(response);
}
const postSearch = (request: SearchRequest, domain: string) =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const response = await search(request, orgId);
return response;
}))

View file

@ -5,14 +5,10 @@ import { getFileSource } from "@/lib/server/searchService";
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
import { getCurrentUserOrg } from "@/auth";
import { withAuth, withOrgMembership } from "@/actions";
import { FileSourceRequest } from "@/lib/types";
export const POST = async (request: NextRequest) => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return serviceErrorResponse(orgId);
}
const body = await request.json();
const parsed = await fileSourceRequestSchema.safeParseAsync(body);
if (!parsed.success) {
@ -21,10 +17,19 @@ export const POST = async (request: NextRequest) => {
);
}
const response = await getFileSource(parsed.data, orgId);
const response = await postSource(parsed.data, request.headers.get("X-Org-Domain")!);
if (isServiceError(response)) {
return serviceErrorResponse(response);
}
return Response.json(response);
}
const postSource = (request: FileSourceRequest, domain: string) =>
withAuth(async (session) =>
withOrgMembership(session, domain, async (orgId) => {
const response = await getFileSource(request, orgId);
return response;
}));

View file

@ -1,80 +0,0 @@
'use client';
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusCircledIcon } from "@radix-ui/react-icons";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(2).max(40),
});
interface OrgCreationDialogProps {
onSubmit: (data: z.infer<typeof formSchema>) => void;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export const OrgCreationDialog = ({
onSubmit,
isOpen,
onOpenChange,
}: OrgCreationDialogProps) => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
},
});
return (
<Dialog
open={isOpen}
onOpenChange={onOpenChange}
>
<DialogTrigger asChild>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
<Button
variant="ghost"
size="default"
className="w-full justify-start gap-1.5 p-0"
>
<PlusCircledIcon className="h-5 w-5 text-muted-foreground" />
Create organization
</Button>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create an organization</DialogTitle>
<DialogDescription>Organizations allow you to collaborate with team members.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Organization name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-5" type="submit">Submit</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View file

@ -56,7 +56,7 @@ export default async function Login(props: {
"use server"
try {
await signIn(provider.id, {
redirectTo: props.searchParams?.callbackUrl ?? "",
redirectTo: props.searchParams?.callbackUrl ?? "/",
})
} catch (error) {
// Signin can fail for a number of reasons, such as the user

View file

@ -1,4 +1,4 @@
import { PageNotFound } from "./components/pageNotFound";
import { PageNotFound } from "./[domain]/components/pageNotFound";
export default function NotFoundPage() {
return (

View file

@ -0,0 +1,98 @@
"use client"
import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { createOrg } from "@/actions"
const formSchema = z.object({
organizationName: z.string().min(2, {
message: "Organization name must be at least 2 characters.",
}),
organizationDomain: z.string().regex(/^[a-z-]+$/, {
message: "Domain can only contain lowercase letters and dashes.",
}),
})
export default function Onboard() {
const [_defaultDomain, setDefaultDomain] = useState("");
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
organizationName: "",
organizationDomain: "",
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
createOrg(values.organizationName, values.organizationDomain)
.then(() => {
})
}
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value
const domain = name.toLowerCase().replace(/\s+/g, "-")
setDefaultDomain(domain)
form.setValue("organizationDomain", domain)
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Create Organization</CardTitle>
<CardDescription>Enter your organization details below.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="organizationName"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormControl>
<Input
placeholder="Acme Inc"
{...field}
onChange={(e) => {
field.onChange(e)
handleNameChange(e)
}}
/>
</FormControl>
<FormDescription>{`This is your organization's full name.`}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="organizationDomain"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Domain</FormLabel>
<FormControl>
<Input placeholder="acme-inc" {...field} />
</FormControl>
<FormDescription>{`This will be used for your organization's URL.`}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
</CardContent>
</Card>
)
}

View file

@ -1,199 +1,35 @@
import { listRepositories } from "@/lib/server/searchService";
import { isServiceError } from "@/lib/utils";
import Image from "next/image";
import { Suspense } from "react";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
import { NavigationMenu } from "./components/navigationMenu";
import { RepositoryCarousel } from "./components/repositoryCarousel";
import { SearchBar } from "./components/searchBar";
import { Separator } from "@/components/ui/separator";
import { SymbolIcon } from "@radix-ui/react-icons";
import { UpgradeToast } from "./components/upgradeToast";
import Link from "next/link";
import { getCurrentUserOrg } from "../auth"
import { auth } from "@/auth";
import { prisma } from "@/prisma";
import { redirect } from "next/navigation";
export default async function Home() {
const orgId = await getCurrentUserOrg();
return (
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<NavigationMenu />
<UpgradeToast />
{isServiceError(orgId) ? (
<div className="mt-8 text-red-500">
You are not authenticated. Please log in to continue.
</div>
) : (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<SearchBar
autoFocus={true}
className="mt-4 w-full max-w-[800px]"
/>
<div className="mt-8">
<Suspense fallback={<div>...</div>}>
<RepositoryList orgId={orgId}/>
</Suspense>
</div>
<div className="flex flex-col items-center w-fit gap-6">
<Separator className="mt-5" />
<span className="font-semibold">How to search</span>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<HowToSection
title="Search in files or paths"
>
<QueryExample>
<Query query="test todo">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>
</QueryExample>
<QueryExample>
<Query query={`"exit boot"`}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
</QueryExample>
<QueryExample>
<Query query="TODO case:yes">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>
</QueryExample>
<QueryExample>
<Query query="repo:torvalds/linux test"><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>
</QueryExample>
<QueryExample>
<Query query="rev:HEAD"><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>
</QueryExample>
<QueryExample>
<Query query="sym:main"><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>
</QueryExample>
<QueryExample>
<Query query="content:README"><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
</QueryExample>
</HowToSection>
</div>
</div>
</div>
)}
<footer className="w-full mt-auto py-4 flex flex-row justify-center items-center gap-4">
<Link href="https://sourcebot.dev" className="text-gray-400 text-sm hover:underline">About</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="https://github.com/sourcebot-dev/sourcebot/issues/new" className="text-gray-400 text-sm hover:underline">Support</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="mailto:team@sourcebot.dev" className="text-gray-400 text-sm hover:underline">Contact Us</Link>
</footer>
</div>
)
}
const RepositoryList = async ({ orgId }: { orgId: number}) => {
const _repos = await listRepositories(orgId);
if (isServiceError(_repos)) {
return null;
export default async function Page() {
const session = await auth();
if (!session) {
return redirect("/login");
}
const repos = _repos.List.Repos.map((repo) => repo.Repository);
const firstOrg = await prisma.userToOrg.findFirst({
where: {
userId: session.user.id,
org: {
members: {
some: {
userId: session.user.id,
}
}
}
},
include: {
org: true
},
orderBy: {
joinedAt: "asc"
}
});
if (repos.length === 0) {
return (
<div className="flex flex-row items-center gap-3">
<SymbolIcon className="h-4 w-4 animate-spin" />
<span className="text-sm">indexing in progress...</span>
</div>
)
if (!firstOrg) {
return redirect("/onboard");
}
return (
<div className="flex flex-col items-center gap-3">
<span className="text-sm">
{`Search ${repos.length} `}
<Link
href="/repos"
className="text-blue-500"
>
{repos.length > 1 ? 'repositories' : 'repository'}
</Link>
</span>
<RepositoryCarousel repos={repos} />
</div>
)
}
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
return (
<div className="flex flex-col gap-1">
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
{children}
</div>
)
}
const Highlight = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-highlight">
{children}
</span>
)
}
const QueryExample = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-sm font-mono">
{children}
</span>
)
}
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-gray-500 dark:text-gray-400 ml-3">
{children}
</span>
)
}
const Query = ({ query, children }: { query: string, children: React.ReactNode }) => {
return (
<Link
href={`/search?query=${query}`}
className="cursor-pointer hover:underline"
>
{children}
</Link>
)
return redirect(`/${firstOrg.org.domain}`);
}

View file

@ -1,6 +1,5 @@
import { prisma } from "@/prisma";
import { notFound, redirect } from 'next/navigation';
import { NavigationMenu } from "../components/navigationMenu";
import { auth } from "@/auth";
import { getUser } from "@/data/user";
import { AcceptInviteButton } from "./components/acceptInviteButton"
@ -9,9 +8,9 @@ interface RedeemPageProps {
searchParams?: {
invite_id?: string;
};
}
}
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
const invite_id = searchParams?.invite_id;
if (!invite_id) {
@ -25,7 +24,6 @@ interface RedeemPageProps {
if (!invite) {
return (
<div>
<NavigationMenu />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<h1>This invite either expired or was revoked. Contact your organization owner.</h1>
</div>
@ -45,7 +43,6 @@ interface RedeemPageProps {
if (user.email !== invite.recipientEmail) {
return (
<div>
<NavigationMenu />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<h1>Sorry this invite does not belong to you.</h1>
</div>
@ -60,7 +57,6 @@ interface RedeemPageProps {
if (!orgName) {
return (
<div>
<NavigationMenu />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<h1>Organization not found. Please contact the invite sender.</h1>
</div>
@ -70,7 +66,6 @@ interface RedeemPageProps {
return (
<div>
<NavigationMenu />
<div className="flex justify-between items-center h-screen px-6">
<h1 className="text-2xl font-bold">You have been invited to org {orgName.name}</h1>
<AcceptInviteButton invite={invite} userId={user.id} />

View file

@ -1,13 +0,0 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
return (
<NextThemesProvider {...props}>
{children}
</NextThemesProvider>
)
}

View file

@ -1,14 +1,13 @@
import 'next-auth/jwt';
import NextAuth, { User as AuthJsUser, DefaultSession } from "next-auth"
import NextAuth, { DefaultSession } from "next-auth"
import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/prisma";
import type { Provider } from "next-auth/providers"
import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET, AUTH_SECRET } from "./lib/environment";
import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET, AUTH_SECRET, AUTH_URL } from "./lib/environment";
import { User } from '@sourcebot/db';
import { notAuthenticated, notFound, unexpectedError } from "@/lib/serviceError";
import { getUser } from "./data/user";
import 'next-auth/jwt';
import type { Provider } from "next-auth/providers";
declare module 'next-auth' {
interface Session {
@ -22,7 +21,7 @@ declare module 'next-auth/jwt' {
interface JWT {
userId: string
}
}
}
const providers: Provider[] = [
GitHub({
@ -47,46 +46,9 @@ export const providerMap = providers
})
.filter((provider) => provider.id !== "credentials");
const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
if (!user.id) {
throw new Error("User ID is required.");
}
const orgName = (() => {
if (user.name) {
return `${user.name}'s Org`;
} else {
return `Default Org`;
}
})();
await prisma.$transaction((async (tx) => {
const org = await tx.org.create({
data: {
name: orgName,
members: {
create: {
role: "OWNER",
user: {
connect: {
id: user.id,
}
}
}
}
}
});
await tx.user.update({
where: {
id: user.id,
},
data: {
activeOrgId: org.id,
}
});
}));
}
const useSecureCookies = AUTH_URL.startsWith("https://");
const hostName = new URL(AUTH_URL).hostname;
export const { handlers, signIn, signOut, auth } = NextAuth({
secret: AUTH_SECRET,
@ -94,9 +56,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt",
},
events: {
createUser: onCreateUser,
},
callbacks: {
async jwt({ token, user: _user }) {
const user = _user as User | undefined;
@ -116,6 +75,37 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
id: token.userId,
}
return session;
},
},
cookies: {
sessionToken: {
name: `${useSecureCookies ? '__Secure-' : ''}authjs.session-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies,
domain: `.${hostName}`
}
},
callbackUrl: {
name: `${useSecureCookies ? '__Secure-' : ''}authjs.callback-url`,
options: {
sameSite: 'lax',
path: '/',
secure: useSecureCookies,
domain: `.${hostName}`
}
},
csrfToken: {
name: `${useSecureCookies ? '__Host-' : ''}authjs.csrf-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies,
domain: `.${hostName}`
}
}
},
providers: providers,
@ -123,33 +113,3 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: "/login"
}
});
export const getCurrentUserOrg = async () => {
const session = await auth();
if (!session) {
return notAuthenticated();
}
const user = await getUser(session.user.id);
if (!user) {
return unexpectedError("User not found");
}
const orgId = user.activeOrgId;
if (!orgId) {
return unexpectedError("User has no active org");
}
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: session.user.id,
orgId,
}
},
});
if (!membership) {
return notFound();
}
return orgId;
}

View file

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View file

@ -0,0 +1,12 @@
import { prisma } from '@/prisma';
import 'server-only';
export const getOrgFromDomain = async (domain: string) => {
const org = await prisma.org.findUnique({
where: {
domain: domain
}
});
return org;
}

View file

@ -0,0 +1,8 @@
'use client';
import { useParams } from "next/navigation";
export const useDomain = () => {
const { domain } = useParams<{ domain: string }>();
return domain;
}

View file

@ -12,3 +12,4 @@ export const AUTH_GITHUB_CLIENT_ID = getEnv(process.env.AUTH_GITHUB_CLIENT_ID);
export const AUTH_GITHUB_CLIENT_SECRET = getEnv(process.env.AUTH_GITHUB_CLIENT_SECRET);
export const AUTH_GOOGLE_CLIENT_ID = getEnv(process.env.AUTH_GOOGLE_CLIENT_ID);
export const AUTH_GOOGLE_CLIENT_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET);
export const AUTH_URL = getEnv(process.env.AUTH_URL)!;

View file

@ -1,9 +1,9 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import githubLogo from "../../public/github.svg";
import gitlabLogo from "../../public/gitlab.svg";
import giteaLogo from "../../public/gitea.svg";
import gerritLogo from "../../public/gerrit.svg";
import githubLogo from "@/public/github.svg";
import gitlabLogo from "@/public/gitlab.svg";
import giteaLogo from "@/public/gitea.svg";
import gerritLogo from "@/public/gerrit.svg";
import { ServiceError } from "./serviceError";
import { Repository } from "./types";

View file

@ -1,55 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "./auth"
import { auth } from "@/auth";
import { Session } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { notAuthenticated, serviceErrorResponse } from "./lib/serviceError";
export default auth((request) => {
const host = request.headers.get("host")!;
interface NextAuthRequest extends NextRequest {
auth: Session | null;
const searchParams = request.nextUrl.searchParams.toString();
const path = `${request.nextUrl.pathname}${
searchParams.length > 0 ? `?${searchParams}` : ""
}`;
if (
host === process.env.NEXT_PUBLIC_ROOT_DOMAIN ||
host === 'localhost:3000'
) {
if (request.nextUrl.pathname === "/login" && request.auth) {
return NextResponse.redirect(new URL("/", request.url));
}
const apiMiddleware = (req: NextAuthRequest) => {
if (req.nextUrl.pathname.startsWith("/api/auth")) {
return NextResponse.next();
}
if (!req.auth) {
return serviceErrorResponse(
notAuthenticated(),
);
}
return NextResponse.next();
}
const defaultMiddleware = (req: NextAuthRequest) => {
// if we're trying to redeem an invite while not authed we continue to the redeem page so
// that we can pipe the invite_id to the login page
if (!req.auth && req.nextUrl.pathname === "/redeem") {
return NextResponse.next();
}
if (!req.auth && req.nextUrl.pathname !== "/login") {
const newUrl = new URL("/login", req.nextUrl.origin);
return NextResponse.redirect(newUrl);
} else if (req.auth && req.nextUrl.pathname === "/login") {
const newUrl = new URL("/", req.nextUrl.origin);
return NextResponse.redirect(newUrl);
}
return NextResponse.next();
}
export default auth(async (req) => {
if (req.nextUrl.pathname.startsWith("/api")) {
return apiMiddleware(req);
}
return defaultMiddleware(req);
})
const subdomain = host.split(".")[0];
return NextResponse.rewrite(new URL(`/${subdomain}${path}`, request.url));
});
export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: ['/((?!_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
matcher: [
/**
* Match all paths except for:
* 1. /api routes
* 2. _next/ routes
* 3. ingest (PostHog route)
*/
'/((?!api|_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'
],
}

View file

@ -141,10 +141,6 @@
["docs", "core"]
]
},
"tenantId": {
"type": "number",
"description": "@nocheckin"
},
"exclude": {
"type": "object",
"properties": {

View file

@ -23,7 +23,7 @@ redirect_stderr=true
[program:backend]
command=./prefix-output.sh node packages/backend/dist/index.js --configPath %(ENV_CONFIG_PATH)s --cacheDir %(ENV_DATA_CACHE_DIR)s
command=./prefix-output.sh node packages/backend/dist/index.js --cacheDir %(ENV_DATA_CACHE_DIR)s
autostart=true
autorestart=true
startretries=3

1896
yarn.lock

File diff suppressed because it is too large Load diff