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 ARG NEXT_PUBLIC_SOURCEBOT_VERSION=BAKED_NEXT_PUBLIC_SOURCEBOT_VERSION
ENV NEXT_PUBLIC_POSTHOG_PAPIK=BAKED_NEXT_PUBLIC_POSTHOG_PAPIK 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, # @nocheckin: This was interfering with the the `matcher` regex in middleware.ts,
# causing regular expressions parsing errors when making a request. It's unclear # 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 # 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 NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV DATA_DIR=/data ENV DATA_DIR=/data
ENV CONFIG_PATH=$DATA_DIR/config.json
ENV DATA_CACHE_DIR=$DATA_DIR/.sourcebot ENV DATA_CACHE_DIR=$DATA_DIR/.sourcebot
ENV DB_DATA_DIR=$DATA_CACHE_DIR/db ENV DB_DATA_DIR=$DATA_CACHE_DIR/db
ENV DB_NAME=sourcebot ENV DB_NAME=sourcebot
ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot" ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot"
ENV SRC_TENANT_ENFORCEMENT_MODE=strict
ARG SOURCEBOT_VERSION=unknown ARG SOURCEBOT_VERSION=unknown
ENV SOURCEBOT_VERSION=$SOURCEBOT_VERSION ENV SOURCEBOT_VERSION=$SOURCEBOT_VERSION
RUN echo "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 # Valid values are: debug, info, warn, error
ENV SOURCEBOT_LOG_LEVEL=info ENV SOURCEBOT_LOG_LEVEL=info

View file

@ -86,27 +86,6 @@ fi
echo "{\"version\": \"$SOURCEBOT_VERSION\", \"install_id\": \"$SOURCEBOT_INSTALL_ID\"}" > "$FIRST_RUN_FILE" 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. # Update NextJs public env variables w/o requiring a rebuild.
# @see: https://phase.dev/blog/nextjs-public-runtime-variables/ # @see: https://phase.dev/blog/nextjs-public-runtime-variables/

View file

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

View file

@ -5,7 +5,7 @@
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "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", "dev": "export PATH=\"$PWD/../../bin:$PATH\" && export CTAGS_COMMAND=ctags && node ./dist/index.js",
"build": "tsc", "build": "tsc",
"test": "vitest --config ./vitest.config.ts" "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) => { export const getEnv = (env: string | undefined, defaultValue?: string, required?: boolean) => {
if (required && !env && !defaultValue) { if (required && !env && !defaultValue) {
throw new Error(`Missing required environment variable`); throw new Error(`Missing required environment variable: ${env}`);
} }
return env ?? defaultValue; 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_LOG_LEVEL = getEnv(process.env.SOURCEBOT_LOG_LEVEL, 'info')!;
export const SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.env.SOURCEBOT_TELEMETRY_DISABLED, false)!; export const SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.env.SOURCEBOT_TELEMETRY_DISABLED, false)!;
export const SOURCEBOT_INSTALL_ID = getEnv(process.env.SOURCEBOT_INSTALL_ID, 'unknown')!; 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 { existsSync } from 'fs';
import { mkdir } from 'fs/promises'; import { mkdir } from 'fs/promises';
import path from 'path'; import path from 'path';
import { isRemotePath } from "./utils.js";
import { AppContext } from "./types.js"; import { AppContext } from "./types.js";
import { main } from "./main.js" import { main } from "./main.js"
import { PrismaClient } from "@sourcebot/db"; import { PrismaClient } from "@sourcebot/db";
import { SOURCEBOT_TENANT_MODE } from "./environment.js";
const parser = new ArgumentParser({ const parser = new ArgumentParser({
@ -18,22 +16,12 @@ type Arguments = {
cacheDir: string; cacheDir: string;
} }
parser.add_argument("--configPath", {
help: "Path to config file",
required: SOURCEBOT_TENANT_MODE === "single",
});
parser.add_argument("--cacheDir", { parser.add_argument("--cacheDir", {
help: "Path to .sourcebot cache directory", help: "Path to .sourcebot cache directory",
required: true, required: true,
}); });
const args = parser.parse_args() as Arguments; 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 cacheDir = args.cacheDir;
const reposPath = path.join(cacheDir, 'repos'); const reposPath = path.join(cacheDir, 'repos');
const indexPath = path.join(cacheDir, 'index'); 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 { model Org {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
domain String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
members UserToOrg[] members UserToOrg[]
@ -161,7 +162,6 @@ model User {
image String? image String?
accounts Account[] accounts Account[]
orgs UserToOrg[] orgs UserToOrg[]
activeOrgId Int?
/// List of pending invites that the user has created /// List of pending invites that the user has created
invites Invite[] invites Invite[]

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
'use server'; 'use server';
import Ajv from "ajv"; import Ajv from "ajv";
import { auth, getCurrentUserOrg } from "./auth"; import { auth } from "./auth";
import { notAuthenticated, notFound, ServiceError, unexpectedError } from "@/lib/serviceError"; import { notAuthenticated, notFound, ServiceError, unexpectedError } from "@/lib/serviceError";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
@ -12,255 +12,318 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { encrypt } from "@sourcebot/crypto" import { encrypt } from "@sourcebot/crypto"
import { getConnection } from "./data/connection"; 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({ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
}); });
export const createSecret = async (key: string, value: string): Promise<{ success: boolean } | ServiceError> => { export const withAuth = async <T>(fn: (session: Session) => Promise<T>) => {
const orgId = await getCurrentUserOrg(); const session = await auth();
if (isServiceError(orgId)) { if (!session) {
return orgId; return notAuthenticated();
}
try {
const encrypted = encrypt(value);
await prisma.secret.create({
data: {
orgId,
key,
encryptedValue: encrypted.encryptedData,
iv: encrypted.iv,
}
});
} catch {
return unexpectedError(`Failed to create secret`);
}
return {
success: true,
} }
return fn(session);
} }
export const getSecrets = async (): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => { export const withOrgMembership = async <T>(session: Session, domain: string, fn: (orgId: number) => Promise<T>) => {
const orgId = await getCurrentUserOrg(); const org = await prisma.org.findUnique({
if (isServiceError(orgId)) {
return orgId;
}
const secrets = await prisma.secret.findMany({
where: { where: {
orgId, domain,
}, },
select: {
key: true,
createdAt: true
}
}); });
return secrets.map((secret) => ({ if (!org) {
key: secret.key, return notFound();
createdAt: secret.createdAt,
}));
}
export const deleteSecret = async (key: string): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
} }
await prisma.secret.delete({
where: {
orgId_key: {
orgId,
key,
}
}
});
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({ const membership = await prisma.userToOrg.findUnique({
where: { where: {
orgId_userId: { orgId_userId: {
userId: session.user.id, userId: session.user.id,
orgId, orgId: org.id,
} }
}, },
}); });
if (!membership) { if (!membership) {
return notFound(); return notFound();
} }
// Update the user's active org return fn(org.id);
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
activeOrgId: orgId,
}
});
return {
id: orgId,
}
} }
export const createConnection = async (name: string, type: string, connectionConfig: string): Promise<{ id: number } | ServiceError> => { export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> =>
const orgId = await getCurrentUserOrg(); withAuth(async (session) => {
if (isServiceError(orgId)) { const org = await prisma.org.create({
return orgId; data: {
} name,
domain,
members: {
create: {
role: "OWNER",
user: {
connect: {
id: session.user.id,
}
}
}
}
}
});
const parsedConfig = parseConnectionConfig(type, connectionConfig);
if (isServiceError(parsedConfig)) {
return parsedConfig;
}
const connection = await prisma.connection.create({
data: {
orgId,
name,
config: parsedConfig as unknown as Prisma.InputJsonValue,
connectionType: type,
}
});
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;
}
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
}
await prisma.connection.update({
where: {
id: connectionId,
orgId,
},
data: {
name,
}
});
return {
success: true,
}
}
export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
}
const parsedConfig = parseConnectionConfig(connection.connectionType, config);
if (isServiceError(parsedConfig)) {
return parsedConfig;
}
if (connection.syncStatus === "SYNC_NEEDED" ||
connection.syncStatus === "IN_SYNC_QUEUE" ||
connection.syncStatus === "SYNCING") {
return { return {
statusCode: StatusCodes.BAD_REQUEST, id: org.id,
errorCode: ErrorCode.CONNECTION_SYNC_ALREADY_SCHEDULED,
message: "Connection is already syncing. Please wait for the sync to complete before updating the connection.",
} satisfies ServiceError;
}
await prisma.connection.update({
where: {
id: connectionId,
orgId,
},
data: {
config: parsedConfig as unknown as Prisma.InputJsonValue,
syncStatus: "SYNC_NEEDED",
} }
}); });
return { export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> =>
success: true, withAuth((session) =>
} withOrgMembership(session, domain, async (orgId) => {
} const secrets = await prisma.secret.findMany({
where: {
orgId,
},
select: {
key: true,
createdAt: true
}
});
export const deleteConnection = async (connectionId: number): Promise<{ success: boolean } | ServiceError> => { return secrets.map((secret) => ({
const orgId = await getCurrentUserOrg(); key: secret.key,
if (isServiceError(orgId)) { createdAt: secret.createdAt,
return orgId; }));
}
const connection = await getConnection(connectionId, orgId); }));
if (!connection) {
return notFound();
}
await prisma.connection.delete({ export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
where: { withAuth((session) =>
id: connectionId, withOrgMembership(session, domain, async (orgId) => {
orgId, try {
const encrypted = encrypt(value);
await prisma.secret.create({
data: {
orgId,
key,
encryptedValue: encrypted.encryptedData,
iv: encrypted.iv,
}
});
} catch {
return unexpectedError(`Failed to create secret`);
}
return {
success: true,
}
}));
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: {
orgId,
key,
}
}
});
return {
success: true,
}
}));
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,
},
});
return connections.map((connection) => ({
id: connection.id,
name: connection.name,
syncStatus: connection.syncStatus,
connectionType: connection.connectionType,
updatedAt: connection.updatedAt,
syncedAt: connection.syncedAt ?? undefined,
}));
})
);
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;
}
const connection = await prisma.connection.create({
data: {
orgId,
name,
config: parsedConfig as unknown as Prisma.InputJsonValue,
connectionType: type,
}
});
return {
id: connection.id,
}
}));
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();
}
await prisma.connection.update({
where: {
id: connectionId,
orgId,
},
data: {
name,
}
});
return {
success: true,
}
}));
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();
}
const parsedConfig = parseConnectionConfig(connection.connectionType, config);
if (isServiceError(parsedConfig)) {
return parsedConfig;
}
if (connection.syncStatus === "SYNC_NEEDED" ||
connection.syncStatus === "IN_SYNC_QUEUE" ||
connection.syncStatus === "SYNCING") {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.CONNECTION_SYNC_ALREADY_SCHEDULED,
message: "Connection is already syncing. Please wait for the sync to complete before updating the connection.",
} satisfies ServiceError;
}
await prisma.connection.update({
where: {
id: connectionId,
orgId,
},
data: {
config: parsedConfig as unknown as Prisma.InputJsonValue,
syncStatus: "SYNC_NEEDED",
}
});
return {
success: true,
}
}));
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();
}
await prisma.connection.delete({
where: {
id: connectionId,
orgId,
}
});
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");
} }
}); });
return {
success: true,
}
}
const parseConnectionConfig = (connectionType: string, config: string) => { const parseConnectionConfig = (connectionType: string, config: string) => {
let parsedConfig: ConnectionConfig; let parsedConfig: ConnectionConfig;
try { try {
@ -301,58 +364,3 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
return parsedConfig; 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 { FileHeader } from "@/app/[domain]/components/fireHeader";
import { TopBar } from "@/app/components/topBar"; import { TopBar } from "@/app/[domain]/components/topBar";
import { Separator } from '@/components/ui/separator'; 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/components/pageNotFound"; 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 { getCurrentUserOrg } from "@/auth"; import { getOrgFromDomain } from "@/data/org";
interface BrowsePageProps { interface BrowsePageProps {
params: { params: {
path: string[]; path: string[];
domain: string;
}; };
} }
@ -45,18 +46,14 @@ export default async function BrowsePage({
} }
})(); })();
const orgId = await getCurrentUserOrg(); const org = await getOrgFromDomain(params.domain);
if (isServiceError(orgId)) { if (!org) {
return ( return <PageNotFound />
<>
Error: {orgId.message}
</>
)
} }
// @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(orgId); const reposResponse = await listRepositories(org.id);
if (isServiceError(reposResponse)) { if (isServiceError(reposResponse)) {
// @todo : proper error handling // @todo : proper error handling
return ( return (
@ -81,6 +78,7 @@ export default async function BrowsePage({
<div className='sticky top-0 left-0 right-0 z-10'> <div className='sticky top-0 left-0 right-0 z-10'>
<TopBar <TopBar
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `} defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
domain={params.domain}
/> />
<Separator /> <Separator />
{repo && ( {repo && (
@ -108,7 +106,7 @@ export default async function BrowsePage({
path={path} path={path}
repoName={repoName} repoName={repoName}
revisionName={revisionName ?? 'HEAD'} revisionName={revisionName ?? 'HEAD'}
orgId={orgId} orgId={org.id}
/> />
)} )}
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,6 +19,7 @@ import { updateConnectionConfigAndScheduleSync } from "@/actions";
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
interface ConfigSettingProps { interface ConfigSettingProps {
@ -61,6 +62,7 @@ function ConfigSettingInternal<T>({
}) { }) {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const domain = useDomain();
const formSchema = useMemo(() => { const formSchema = useMemo(() => {
return z.object({ return z.object({
config: createZodConnectionConfigValidator(schema), config: createZodConnectionConfigValidator(schema),
@ -77,7 +79,7 @@ function ConfigSettingInternal<T>({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => { const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
setIsLoading(true); setIsLoading(true);
updateConnectionConfigAndScheduleSync(connectionId, data.config) updateConnectionConfigAndScheduleSync(connectionId, data.config, domain)
.then((response) => { .then((response) => {
if (isServiceError(response)) { if (isServiceError(response)) {
toast({ toast({
@ -94,7 +96,7 @@ function ConfigSettingInternal<T>({
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
}) })
}, [connectionId, router, toast]); }, [connectionId, domain, router, toast]);
return ( return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6"> <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 { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
interface DeleteConnectionSettingProps { interface DeleteConnectionSettingProps {
connectionId: number; connectionId: number;
@ -28,13 +29,14 @@ export const DeleteConnectionSetting = ({
}: DeleteConnectionSettingProps) => { }: DeleteConnectionSettingProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const domain = useDomain();
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
setIsDialogOpen(false); setIsDialogOpen(false);
setIsLoading(true); setIsLoading(true);
deleteConnection(connectionId) deleteConnection(connectionId, domain)
.then((response) => { .then((response) => {
if (isServiceError(response)) { if (isServiceError(response)) {
toast({ toast({
@ -44,14 +46,14 @@ export const DeleteConnectionSetting = ({
toast({ toast({
description: `✅ Connection deleted successfully.` description: `✅ Connection deleted successfully.`
}); });
router.replace("/connections"); router.replace(`/${domain}/connections`);
router.refresh(); router.refresh();
} }
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
}, [connectionId]); }, [connectionId, domain, router, toast]);
return ( return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6"> <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 { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@ -28,6 +29,7 @@ export const DisplayNameSetting = ({
}: DisplayNameSettingProps) => { }: DisplayNameSettingProps) => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const domain = useDomain();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@ -38,7 +40,7 @@ export const DisplayNameSetting = ({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => { const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
setIsLoading(true); setIsLoading(true);
updateConnectionDisplayName(connectionId, data.name) updateConnectionDisplayName(connectionId, data.name, domain)
.then((response) => { .then((response) => {
if (isServiceError(response)) { if (isServiceError(response)) {
toast({ toast({
@ -53,7 +55,7 @@ export const DisplayNameSetting = ({
}).finally(() => { }).finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
}, [connectionId, router, toast]); }, [connectionId, domain, router, toast]);
return ( return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6"> <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 { NotFound } from "@/app/[domain]/components/notFound";
import { getCurrentUserOrg } from "@/auth";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@ -12,17 +11,19 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { TabSwitcher } from "@/components/ui/tab-switcher"; import { TabSwitcher } from "@/components/ui/tab-switcher";
import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs";
import { getConnection, getLinkedRepos } from "@/data/connection"; import { getConnection, getLinkedRepos } from "@/data/connection";
import { isServiceError } from "@/lib/utils";
import { ConnectionIcon } from "../components/connectionIcon"; import { ConnectionIcon } from "../components/connectionIcon";
import { Header } from "../../components/header"; import { Header } from "../../components/header";
import { ConfigSetting } from "./components/configSetting"; 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 { RepoListItem } from "./components/repoListItem"; import { RepoListItem } from "./components/repoListItem";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "../../components/pageNotFound";
interface ConnectionManagementPageProps { interface ConnectionManagementPageProps {
params: { params: {
id: string; id: string;
domain: string;
}, },
searchParams: { searchParams: {
tab?: string; tab?: string;
@ -33,13 +34,9 @@ export default async function ConnectionManagementPage({
params, params,
searchParams, searchParams,
}: ConnectionManagementPageProps) { }: ConnectionManagementPageProps) {
const orgId = await getCurrentUserOrg(); const org = await getOrgFromDomain(params.domain);
if (isServiceError(orgId)) { if (!org) {
return ( return <PageNotFound />
<>
Error: {orgId.message}
</>
)
} }
const connectionId = Number(params.id); 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) { if (!connection) {
return ( return (
<NotFound <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"; const currentTab = searchParams.tab || "overview";
@ -78,7 +75,7 @@ export default async function ConnectionManagementPage({
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink href="/connections">Connections</BreadcrumbLink> <BreadcrumbLink href={`/${params.domain}/connections`}>Connections</BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem> <BreadcrumbItem>

View file

@ -53,7 +53,7 @@ export const ConnectionListItem = ({
}, [status]); }, [status]);
return ( return (
<Link href={`/connections/${id}`}> <Link href={`connections/${id}`}>
<div <div
className="flex flex-row justify-between items-center border p-4 rounded-lg cursor-pointer bg-background" 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 { ConnectionListItem } from "./connectionListItem";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { InfoCircledIcon } from "@radix-ui/react-icons"; import { InfoCircledIcon } from "@radix-ui/react-icons";
import { ConnectionSyncStatus } from "@sourcebot/db";
interface ConnectionListProps { interface ConnectionListProps {
connections: Connection[]; connections: {
id: number,
name: string,
connectionType: string,
syncStatus: ConnectionSyncStatus,
updatedAt: Date,
syncedAt?: Date
}[];
className?: string; className?: string;
} }

View file

@ -78,7 +78,7 @@ const Card = ({
return ( return (
<Link <Link
className="flex flex-row justify-between items-center cursor-pointer p-2" 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"> <div className="flex flex-row items-center gap-2">
{Icon} {Icon}

View file

@ -2,13 +2,15 @@ import { NavigationMenu } from "../components/navigationMenu";
export default function Layout({ export default function Layout({
children, children,
params: { domain },
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
params: { domain: string };
}>) { }>) {
return ( return (
<div className="min-h-screen flex flex-col"> <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"> <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> <div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
</main> </main>

View file

@ -2,8 +2,8 @@
'use client'; 'use client';
import { createConnection } from "@/actions"; import { createConnection } from "@/actions";
import { ConnectionIcon } from "@/app/connections/components/connectionIcon"; import { ConnectionIcon } from "@/app/[domain]/connections/components/connectionIcon";
import { createZodConnectionConfigValidator } from "@/app/connections/utils"; import { createZodConnectionConfigValidator } from "@/app/[domain]/connections/utils";
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; 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 { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { ConfigEditor, QuickActionFn } from "../../../components/configEditor"; import { ConfigEditor, QuickActionFn } from "../../../components/configEditor";
import { useDomain } from "@/hooks/useDomain";
interface ConnectionCreationForm<T> { interface ConnectionCreationForm<T> {
type: 'github' | 'gitlab'; type: 'github' | 'gitlab';
@ -41,6 +42,7 @@ export default function ConnectionCreationForm<T>({
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const domain = useDomain();
const formSchema = useMemo(() => { const formSchema = useMemo(() => {
return z.object({ return z.object({
@ -55,7 +57,7 @@ export default function ConnectionCreationForm<T>({
}); });
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => { const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
createConnection(data.name, type, data.config) createConnection(data.name, type, data.config, domain)
.then((response) => { .then((response) => {
if (isServiceError(response)) { if (isServiceError(response)) {
toast({ toast({
@ -65,11 +67,11 @@ export default function ConnectionCreationForm<T>({
toast({ toast({
description: `✅ Connection created successfully.` description: `✅ Connection created successfully.`
}); });
router.push('/connections'); router.push(`/${domain}/connections`);
router.refresh(); router.refresh();
} }
}); });
}, [router, toast, type]); }, [domain, router, toast, type]);
return ( return (
<div className="flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6"> <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 { 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 { isServiceError } from "@/lib/utils";
export default async function ConnectionsPage() { export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) {
const session = await auth(); const connections = await getConnections(domain);
if (!session) { if (isServiceError(connections)) {
return null; 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 ( return (
<div> <div>
<Header> <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 { Suspense } from "react";
import { NavigationMenu } from "../components/navigationMenu"; import { NavigationMenu } from "../components/navigationMenu";
import { RepositoryTable } from "./repositoryTable"; import { RepositoryTable } from "./repositoryTable";
import { getCurrentUserOrg } from "@/auth"; import { getOrgFromDomain } from "@/data/org";
import { isServiceError } from "@/lib/utils"; import { PageNotFound } from "../components/pageNotFound";
export default async function ReposPage() { export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) {
const orgId = await getCurrentUserOrg(); const org = await getOrgFromDomain(domain);
if (isServiceError(orgId)) { if (!org) {
return ( return <PageNotFound />
<>
Error: {orgId.message}
</>
)
} }
return ( return (
<div className="h-screen flex flex-col items-center"> <div className="h-screen flex flex-col items-center">
<NavigationMenu /> <NavigationMenu domain={domain} />
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<div className="max-w-[90%]"> <div className="max-w-[90%]">
<RepositoryTable orgId={ orgId }/> <RepositoryTable orgId={ org.id }/>
</div> </div>
</Suspense> </Suspense>
</div> </div>

View file

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

View file

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

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { FileHeader } from "@/app/components/fireHeader"; import { FileHeader } from "@/app/[domain]/components/fireHeader";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Repository, SearchResultFile } from "@/lib/types"; import { Repository, SearchResultFile } from "@/lib/types";
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons"; 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 { useRouter } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ImperativePanelHandle } from "react-resizable-panels"; 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 { TopBar } from "../components/topBar";
import { CodePreviewPanel } from "./components/codePreviewPanel"; import { CodePreviewPanel } from "./components/codePreviewPanel";
import { FilterPanel } from "./components/filterPanel"; import { FilterPanel } from "./components/filterPanel";
import { SearchResultsPanel } from "./components/searchResultsPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel";
import { useDomain } from "@/hooks/useDomain";
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
@ -42,13 +43,14 @@ const SearchPageInternal = () => {
const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount; const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
const { setSearchHistory } = useSearchHistory(); const { setSearchHistory } = useSearchHistory();
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const domain = useDomain();
const { data: searchResponse, isLoading } = useQuery({ const { data: searchResponse, isLoading } = useQuery({
queryKey: ["search", searchQuery, maxMatchDisplayCount], queryKey: ["search", searchQuery, maxMatchDisplayCount],
queryFn: () => search({ queryFn: () => search({
query: searchQuery, query: searchQuery,
maxMatchDisplayCount, maxMatchDisplayCount,
}), }, domain),
enabled: searchQuery.length > 0, enabled: searchQuery.length > 0,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
@ -75,7 +77,7 @@ const SearchPageInternal = () => {
// for easy lookup. // for easy lookup.
const { data: repoMetadata } = useQuery({ const { data: repoMetadata } = useQuery({
queryKey: ["repos"], queryKey: ["repos"],
queryFn: () => getRepos(), queryFn: () => getRepos(domain),
select: (data): Record<string, Repository> => select: (data): Record<string, Repository> =>
data.List.Repos data.List.Repos
.map(r => r.Repository) .map(r => r.Repository)
@ -185,7 +187,10 @@ const SearchPageInternal = () => {
<div className="flex flex-col h-screen overflow-clip"> <div className="flex flex-col h-screen overflow-clip">
{/* TopBar */} {/* TopBar */}
<div className="sticky top-0 left-0 right-0 z-10"> <div className="sticky top-0 left-0 right-0 z-10">
<TopBar defaultSearchQuery={searchQuery} /> <TopBar
defaultSearchQuery={searchQuery}
domain={domain}
/>
<Separator /> <Separator />
{!isLoading && ( {!isLoading && (
<div className="bg-accent py-1 px-2 flex flex-row items-center gap-4"> <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 { NavigationMenu } from "../components/navigationMenu";
import { SecretsTable } from "./secretsTable"; import { SecretsTable } from "./secretsTable";
import { getSecrets } from "../../actions"
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { getSecrets } from "@/actions";
export interface SecretsTableProps { export default async function SecretsPage({ params: { domain } }: { params: { domain: string } }) {
initialSecrets: { createdAt: Date; key: string; }[]; const secrets = await getSecrets(domain);
}
export default async function SecretsPage() {
const secrets = await getSecrets();
return ( return (
<div className="h-screen flex flex-col items-center"> <div className="h-screen flex flex-col items-center">
<NavigationMenu /> <NavigationMenu domain={domain} />
{ !isServiceError(secrets) && ( { !isServiceError(secrets) && (
<div className="max-w-[90%]"> <div className="max-w-[90%]">
<SecretsTable initialSecrets={secrets} /> <SecretsTable
initialSecrets={secrets}
/>
</div> </div>
)} )}
</div> </div>

View file

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

View file

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

View file

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

View file

@ -2,13 +2,14 @@ import { NavigationMenu } from "../components/navigationMenu";
export default function Layout({ export default function Layout({
children, children,
params: { domain },
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
params: { domain: string };
}>) { }>) {
return ( return (
<div className="min-h-screen flex flex-col"> <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"> <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> <div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
</main> </main>

View file

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

View file

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

View file

@ -1,16 +1,26 @@
'use server'; 'use server';
import { listRepositories } from "@/lib/server/searchService"; 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 { isServiceError } from "@/lib/utils";
import { serviceErrorResponse } from "@/lib/serviceError"; import { serviceErrorResponse } from "@/lib/serviceError";
export const GET = async () => { export const GET = async (request: NextRequest) => {
const orgId = await getCurrentUserOrg(); const domain = request.headers.get("X-Org-Domain")!;
if (isServiceError(orgId)) { const response = await getRepos(domain);
return serviceErrorResponse(orgId);
}
const response = await listRepositories(orgId); if (isServiceError(response)) {
return serviceErrorResponse(response);
}
return Response.json(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 { search } from "@/lib/server/searchService";
import { searchRequestSchema } from "@/lib/schemas"; import { searchRequestSchema } from "@/lib/schemas";
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server"; 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) => { export const POST = async (request: NextRequest) => {
const orgId = await getCurrentUserOrg(); const domain = request.headers.get("X-Org-Domain")!;
if (isServiceError(orgId)) {
return serviceErrorResponse(orgId);
}
console.log(`Searching for org ${orgId}`);
const body = await request.json(); const body = await request.json();
const parsed = await searchRequestSchema.safeParseAsync(body); const parsed = await searchRequestSchema.safeParseAsync(body);
if (!parsed.success) { if (!parsed.success) {
@ -22,11 +18,16 @@ export const POST = async (request: NextRequest) => {
); );
} }
const response = await postSearch(parsed.data, domain);
const response = await search(parsed.data, orgId);
if (isServiceError(response)) { if (isServiceError(response)) {
return serviceErrorResponse(response); return serviceErrorResponse(response);
} }
return Response.json(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 { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server"; 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) => { export const POST = async (request: NextRequest) => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return serviceErrorResponse(orgId);
}
const body = await request.json(); const body = await request.json();
const parsed = await fileSourceRequestSchema.safeParseAsync(body); const parsed = await fileSourceRequestSchema.safeParseAsync(body);
if (!parsed.success) { 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)) { if (isServiceError(response)) {
return serviceErrorResponse(response); return serviceErrorResponse(response);
} }
return Response.json(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" "use server"
try { try {
await signIn(provider.id, { await signIn(provider.id, {
redirectTo: props.searchParams?.callbackUrl ?? "", redirectTo: props.searchParams?.callbackUrl ?? "/",
}) })
} catch (error) { } catch (error) {
// Signin can fail for a number of reasons, such as the user // 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() { export default function NotFoundPage() {
return ( 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 { auth } from "@/auth";
import { isServiceError } from "@/lib/utils"; import { prisma } from "@/prisma";
import Image from "next/image"; import { redirect } from "next/navigation";
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"
export default async function Page() {
export default async function Home() { const session = await auth();
const orgId = await getCurrentUserOrg(); if (!session) {
return redirect("/login");
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;
} }
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) { if (!firstOrg) {
return ( return redirect("/onboard");
<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 ( return redirect(`/${firstOrg.org.domain}`);
<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>
)
} }

View file

@ -9,45 +9,45 @@ import { Button } from "@/components/ui/button"
import { Invite } from "@sourcebot/db" import { Invite } from "@sourcebot/db"
interface AcceptInviteButtonProps { interface AcceptInviteButtonProps {
invite: Invite invite: Invite
userId: string userId: string
} }
export function AcceptInviteButton({ invite, userId }: AcceptInviteButtonProps) { export function AcceptInviteButton({ invite, userId }: AcceptInviteButtonProps) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const router = useRouter() const router = useRouter()
const { toast } = useToast() const { toast } = useToast()
const handleAcceptInvite = async () => { const handleAcceptInvite = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const res = await redeemInvite(invite, userId) const res = await redeemInvite(invite, userId)
if (isServiceError(res)) { if (isServiceError(res)) {
console.log("Failed to redeem invite: ", res) console.log("Failed to redeem invite: ", res)
toast({ toast({
title: "Error", title: "Error",
description: "Failed to redeem invite. Please try again.", description: "Failed to redeem invite. Please try again.",
variant: "destructive", variant: "destructive",
}) })
} else { } else {
router.push("/") router.push("/")
} }
} catch (error) { } catch (error) {
console.error("Error redeeming invite:", error) console.error("Error redeeming invite:", error)
toast({ toast({
title: "Error", title: "Error",
description: "An unexpected error occurred. Please try again.", description: "An unexpected error occurred. Please try again.",
variant: "destructive", variant: "destructive",
}) })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
}
} }
}
return ( return (
<Button onClick={handleAcceptInvite} disabled={isLoading}> <Button onClick={handleAcceptInvite} disabled={isLoading}>
{isLoading ? "Accepting..." : "Accept Invite"} {isLoading ? "Accepting..." : "Accept Invite"}
</Button> </Button>
) )
} }

View file

@ -1,17 +1,16 @@
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { NavigationMenu } from "../components/navigationMenu";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getUser } from "@/data/user"; import { getUser } from "@/data/user";
import { AcceptInviteButton } from "./components/acceptInviteButton" import { AcceptInviteButton } from "./components/acceptInviteButton"
interface RedeemPageProps { interface RedeemPageProps {
searchParams?: { searchParams?: {
invite_id?: string; invite_id?: string;
}; };
} }
export default async function RedeemPage({ searchParams }: RedeemPageProps) { export default async function RedeemPage({ searchParams }: RedeemPageProps) {
const invite_id = searchParams?.invite_id; const invite_id = searchParams?.invite_id;
if (!invite_id) { if (!invite_id) {
@ -25,10 +24,9 @@ interface RedeemPageProps {
if (!invite) { if (!invite) {
return ( return (
<div> <div>
<NavigationMenu /> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}> <h1>This invite either expired or was revoked. Contact your organization owner.</h1>
<h1>This invite either expired or was revoked. Contact your organization owner.</h1> </div>
</div>
</div> </div>
); );
} }
@ -45,10 +43,9 @@ interface RedeemPageProps {
if (user.email !== invite.recipientEmail) { if (user.email !== invite.recipientEmail) {
return ( return (
<div> <div>
<NavigationMenu /> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}> <h1>Sorry this invite does not belong to you.</h1>
<h1>Sorry this invite does not belong to you.</h1> </div>
</div>
</div> </div>
) )
} else { } else {
@ -60,17 +57,15 @@ interface RedeemPageProps {
if (!orgName) { if (!orgName) {
return ( return (
<div> <div>
<NavigationMenu /> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}> <h1>Organization not found. Please contact the invite sender.</h1>
<h1>Organization not found. Please contact the invite sender.</h1> </div>
</div>
</div> </div>
) )
} }
return ( return (
<div> <div>
<NavigationMenu />
<div className="flex justify-between items-center h-screen px-6"> <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> <h1 className="text-2xl font-bold">You have been invited to org {orgName.name}</h1>
<AcceptInviteButton invite={invite} userId={user.id} /> <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 '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 GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google" import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter" import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/prisma"; 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, AUTH_URL } from "./lib/environment";
import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET, AUTH_SECRET } from "./lib/environment";
import { User } from '@sourcebot/db'; import { User } from '@sourcebot/db';
import { notAuthenticated, notFound, unexpectedError } from "@/lib/serviceError"; import 'next-auth/jwt';
import { getUser } from "./data/user"; import type { Provider } from "next-auth/providers";
declare module 'next-auth' { declare module 'next-auth' {
interface Session { interface Session {
@ -20,9 +19,9 @@ declare module 'next-auth' {
declare module 'next-auth/jwt' { declare module 'next-auth/jwt' {
interface JWT { interface JWT {
userId: string userId: string
} }
} }
const providers: Provider[] = [ const providers: Provider[] = [
GitHub({ GitHub({
@ -47,46 +46,9 @@ export const providerMap = providers
}) })
.filter((provider) => provider.id !== "credentials"); .filter((provider) => provider.id !== "credentials");
const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
if (!user.id) {
throw new Error("User ID is required.");
}
const orgName = (() => { const useSecureCookies = AUTH_URL.startsWith("https://");
if (user.name) { const hostName = new URL(AUTH_URL).hostname;
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,
}
});
}));
}
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
secret: AUTH_SECRET, secret: AUTH_SECRET,
@ -94,9 +56,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
session: { session: {
strategy: "jwt", strategy: "jwt",
}, },
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;
@ -116,6 +75,37 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
id: token.userId, id: token.userId,
} }
return session; 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, providers: providers,
@ -123,33 +113,3 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: "/login" 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_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_ID = getEnv(process.env.AUTH_GOOGLE_CLIENT_ID);
export const AUTH_GOOGLE_CLIENT_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET); 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 { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import githubLogo from "../../public/github.svg"; import githubLogo from "@/public/github.svg";
import gitlabLogo from "../../public/gitlab.svg"; import gitlabLogo from "@/public/gitlab.svg";
import giteaLogo from "../../public/gitea.svg"; import giteaLogo from "@/public/gitea.svg";
import gerritLogo from "../../public/gerrit.svg"; import gerritLogo from "@/public/gerrit.svg";
import { ServiceError } from "./serviceError"; import { ServiceError } from "./serviceError";
import { Repository } from "./types"; import { Repository } from "./types";

View file

@ -1,55 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "./auth"
import { auth } from "@/auth"; export default auth((request) => {
import { Session } from "next-auth"; const host = request.headers.get("host")!;
import { NextRequest, NextResponse } from "next/server";
import { notAuthenticated, serviceErrorResponse } from "./lib/serviceError";
interface NextAuthRequest extends NextRequest { const searchParams = request.nextUrl.searchParams.toString();
auth: Session | null; const path = `${request.nextUrl.pathname}${
} searchParams.length > 0 ? `?${searchParams}` : ""
}`;
const apiMiddleware = (req: NextAuthRequest) => { if (
if (req.nextUrl.pathname.startsWith("/api/auth")) { host === process.env.NEXT_PUBLIC_ROOT_DOMAIN ||
host === 'localhost:3000'
) {
if (request.nextUrl.pathname === "/login" && request.auth) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next(); return NextResponse.next();
} }
if (!req.auth) { const subdomain = host.split(".")[0];
return serviceErrorResponse( return NextResponse.rewrite(new URL(`/${subdomain}${path}`, request.url));
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);
})
export const config = { export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher // 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"] ["docs", "core"]
] ]
}, },
"tenantId": {
"type": "number",
"description": "@nocheckin"
},
"exclude": { "exclude": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -23,7 +23,7 @@ redirect_stderr=true
[program:backend] [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 autostart=true
autorestart=true autorestart=true
startretries=3 startretries=3

Some files were not shown because too many files have changed in this diff Show more