diff --git a/Dockerfile b/Dockerfile index 98ecd99b..e9747d7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,10 +45,6 @@ ARG NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED=BAKED_NEXT_PUBLIC_SOURCEBOT_TELEMET ARG NEXT_PUBLIC_SOURCEBOT_VERSION=BAKED_NEXT_PUBLIC_SOURCEBOT_VERSION ENV NEXT_PUBLIC_POSTHOG_PAPIK=BAKED_NEXT_PUBLIC_POSTHOG_PAPIK -# We declare SOURCEBOT_ENCRYPTION_KEY here since it's read during the build stage, since it's read in a server side component -ARG SOURCEBOT_ENCRYPTION_KEY -ENV SOURCEBOT_ENCRYPTION_KEY=$SOURCEBOT_ENCRYPTION_KEY - # @nocheckin: This was interfering with the the `matcher` regex in middleware.ts, # causing regular expressions parsing errors when making a request. It's unclear # why exactly this was happening, but it's likely due to a bad replacement happening @@ -79,21 +75,16 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV DATA_DIR=/data -ENV CONFIG_PATH=$DATA_DIR/config.json ENV DATA_CACHE_DIR=$DATA_DIR/.sourcebot ENV DB_DATA_DIR=$DATA_CACHE_DIR/db ENV DB_NAME=sourcebot ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot" +ENV SRC_TENANT_ENFORCEMENT_MODE=strict ARG SOURCEBOT_VERSION=unknown ENV SOURCEBOT_VERSION=$SOURCEBOT_VERSION RUN echo "Sourcebot Version: $SOURCEBOT_VERSION" -# Redeclare SOURCEBOT_ENCRYPTION_KEY so that we have it in the runner -ARG SOURCEBOT_ENCRYPTION_KEY - -ENV SOURCEBOT_TENANT_MODE=single - # Valid values are: debug, info, warn, error ENV SOURCEBOT_LOG_LEVEL=info diff --git a/entrypoint.sh b/entrypoint.sh index 0d96a120..fda2cbaf 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -86,27 +86,6 @@ fi echo "{\"version\": \"$SOURCEBOT_VERSION\", \"install_id\": \"$SOURCEBOT_INSTALL_ID\"}" > "$FIRST_RUN_FILE" -if [ ! -z "$SOURCEBOT_TENANT_MODE" ]; then - echo -e "\e[34m[Info] Sourcebot tenant mode: $SOURCEBOT_TENANT_MODE\e[0m" -else - echo -e "\e[31m[Error] SOURCEBOT_TENANT_MODE is not set.\e[0m" - exit 1 -fi - -# If we're in single tenant mode, fallback to sample config if a config does not exist -if [ "$SOURCEBOT_TENANT_MODE" = "single" ]; then - if echo "$CONFIG_PATH" | grep -qE '^https?://'; then - if ! curl --output /dev/null --silent --head --fail "$CONFIG_PATH"; then - echo -e "\e[33m[Warning] Remote config file at '$CONFIG_PATH' not found. Falling back on sample config.\e[0m" - CONFIG_PATH="./default-config.json" - fi - elif [ ! -f "$CONFIG_PATH" ]; then - echo -e "\e[33m[Warning] Config file at '$CONFIG_PATH' not found. Falling back on sample config.\e[0m" - CONFIG_PATH="./default-config.json" - fi - - echo -e "\e[34m[Info] Using config file at: '$CONFIG_PATH'.\e[0m" -fi # Update NextJs public env variables w/o requiring a rebuild. # @see: https://phase.dev/blog/nextjs-public-runtime-variables/ diff --git a/package.json b/package.json index d79edcf1..171f3164 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,8 @@ "scripts": { "build": "yarn workspaces run build", "test": "yarn workspaces run test", - "dev": "cross-env SOURCEBOT_TENANT_MODE=single npm-run-all --print-label dev:start", - "dev:mt": "cross-env SOURCEBOT_TENANT_MODE=multi npm-run-all --print-label dev:start:mt", - "dev:start": "yarn workspace @sourcebot/db prisma:migrate:dev && cross-env npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web", - "dev:start:mt": "yarn workspace @sourcebot/db prisma:migrate:dev && cross-env npm-run-all --print-label --parallel dev:zoekt:mt dev:backend dev:web", - "dev:zoekt": "export PATH=\"$PWD/bin:$PATH\" && export SRC_TENANT_ENFORCEMENT_MODE=none && zoekt-webserver -index .sourcebot/index -rpc", - "dev:zoekt:mt": "export PATH=\"$PWD/bin:$PATH\" && export SRC_TENANT_ENFORCEMENT_MODE=strict && zoekt-webserver -index .sourcebot/index -rpc", + "dev": "yarn workspace @sourcebot/db prisma:migrate:dev && cross-env npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web", + "dev:zoekt": "export PATH=\"$PWD/bin:$PATH\" && export SRC_TENANT_ENFORCEMENT_MODE=strict && zoekt-webserver -index .sourcebot/index -rpc", "dev:backend": "yarn workspace @sourcebot/backend dev:watch", "dev:web": "yarn workspace @sourcebot/web dev" }, diff --git a/packages/backend/package.json b/packages/backend/package.json index 02a6f772..16987364 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -5,7 +5,7 @@ "main": "index.js", "type": "module", "scripts": { - "dev:watch": "tsc-watch --preserveWatchOutput --onSuccess \"yarn dev --configPath ../../config.json --cacheDir ../../.sourcebot\"", + "dev:watch": "tsc-watch --preserveWatchOutput --onSuccess \"yarn dev --cacheDir ../../.sourcebot\"", "dev": "export PATH=\"$PWD/../../bin:$PATH\" && export CTAGS_COMMAND=ctags && node ./dist/index.js", "build": "tsc", "test": "vitest --config ./vitest.config.ts" diff --git a/packages/backend/src/environment.ts b/packages/backend/src/environment.ts index 508838f6..a3c2a8e4 100644 --- a/packages/backend/src/environment.ts +++ b/packages/backend/src/environment.ts @@ -2,7 +2,7 @@ import dotenv from 'dotenv'; export const getEnv = (env: string | undefined, defaultValue?: string, required?: boolean) => { if (required && !env && !defaultValue) { - throw new Error(`Missing required environment variable`); + throw new Error(`Missing required environment variable: ${env}`); } return env ?? defaultValue; @@ -20,7 +20,6 @@ dotenv.config({ }); -export const SOURCEBOT_TENANT_MODE = getEnv(process.env.SOURCEBOT_TENANT_MODE, undefined, true); export const SOURCEBOT_LOG_LEVEL = getEnv(process.env.SOURCEBOT_LOG_LEVEL, 'info')!; export const SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.env.SOURCEBOT_TELEMETRY_DISABLED, false)!; export const SOURCEBOT_INSTALL_ID = getEnv(process.env.SOURCEBOT_INSTALL_ID, 'unknown')!; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 9cab5e65..04ec764e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -2,11 +2,9 @@ import { ArgumentParser } from "argparse"; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; import path from 'path'; -import { isRemotePath } from "./utils.js"; import { AppContext } from "./types.js"; import { main } from "./main.js" import { PrismaClient } from "@sourcebot/db"; -import { SOURCEBOT_TENANT_MODE } from "./environment.js"; const parser = new ArgumentParser({ @@ -18,22 +16,12 @@ type Arguments = { cacheDir: string; } -parser.add_argument("--configPath", { - help: "Path to config file", - required: SOURCEBOT_TENANT_MODE === "single", -}); - parser.add_argument("--cacheDir", { help: "Path to .sourcebot cache directory", required: true, }); const args = parser.parse_args() as Arguments; -if (SOURCEBOT_TENANT_MODE === "single" && !isRemotePath(args.configPath) && !existsSync(args.configPath)) { - console.error(`Config file ${args.configPath} does not exist, and is required in single tenant mode`); - process.exit(1); -} - const cacheDir = args.cacheDir; const reposPath = path.join(cacheDir, 'repos'); const indexPath = path.join(cacheDir, 'index'); diff --git a/packages/db/prisma/migrations/20250211195225_add_domain_to_org/migration.sql b/packages/db/prisma/migrations/20250206180955_add_domain/migration.sql similarity index 100% rename from packages/db/prisma/migrations/20250211195225_add_domain_to_org/migration.sql rename to packages/db/prisma/migrations/20250206180955_add_domain/migration.sql diff --git a/packages/db/prisma/migrations/20250212061001_remove_active_org_id/migration.sql b/packages/db/prisma/migrations/20250212061001_remove_active_org_id/migration.sql new file mode 100644 index 00000000..a6799210 --- /dev/null +++ b/packages/db/prisma/migrations/20250212061001_remove_active_org_id/migration.sql @@ -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"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 03fdd39a..8c7e421d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -164,7 +164,6 @@ model User { image String? accounts Account[] orgs UserToOrg[] - activeOrgId Int? /// List of pending invites that the user has created invites Invite[] diff --git a/packages/schemas/src/v2/index.schema.ts b/packages/schemas/src/v2/index.schema.ts index ed475f73..6b0b3453 100644 --- a/packages/schemas/src/v2/index.schema.ts +++ b/packages/schemas/src/v2/index.schema.ts @@ -147,10 +147,6 @@ const schema = { ] ] }, - "tenantId": { - "type": "number", - "description": "@nocheckin" - }, "exclude": { "type": "object", "properties": { diff --git a/packages/schemas/src/v2/index.type.ts b/packages/schemas/src/v2/index.type.ts index 1449d43c..7120c907 100644 --- a/packages/schemas/src/v2/index.type.ts +++ b/packages/schemas/src/v2/index.type.ts @@ -95,10 +95,6 @@ export interface GitHubConfig { * @minItems 1 */ topics?: string[]; - /** - * @nocheckin - */ - tenantId?: number; exclude?: { /** * Exclude forked repositories from syncing. diff --git a/packages/web/package.json b/packages/web/package.json index ba988d86..572fa0b3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -106,6 +106,7 @@ "next-themes": "^0.3.0", "posthog-js": "^1.161.5", "pretty-bytes": "^6.1.1", + "psl": "^1.15.0", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.53.0", @@ -123,6 +124,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/psl": "^1.1.3", "@types/react": "^18", "@types/react-dom": "^18", "@typescript-eslint/eslint-plugin": "^8.3.0", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index d303857f..41583ab6 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -12,279 +12,327 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { encrypt } from "@sourcebot/crypto" import { getConnection } from "./data/connection"; -import { Prisma, Invite } from "@sourcebot/db"; +import { ConnectionSyncStatus, Prisma, Invite } from "@sourcebot/db"; import { headers } from "next/headers" import { stripe } from "@/lib/stripe" import { getUser } from "@/data/user"; +import { Session } from "next-auth"; const ajv = new Ajv({ validateFormats: false, }); -export const createSecret = async (key: string, value: string): Promise<{ success: boolean } | ServiceError> => { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return orgId; - } - - 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 withAuth = async (fn: (session: Session) => Promise) => { + const session = await auth(); + if (!session) { + return notAuthenticated(); } + return fn(session); } -export const getSecrets = async (): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return orgId; - } - - const secrets = await prisma.secret.findMany({ - where: { - orgId, - }, - select: { - key: true, - createdAt: true - } - }); - - return secrets.map((secret) => ({ - key: secret.key, - createdAt: secret.createdAt, - })); -} - -export const deleteSecret = async (key: string): Promise<{ success: boolean } | ServiceError> => { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return orgId; - } - - await prisma.secret.delete({ - where: { - orgId_key: { - orgId, - key, - } - } - }); - - return { - success: true, - } -} - -export const checkIfOrgDomainExists = async (domain: string): Promise => { +export const withOrgMembership = async (session: Session, domain: string, fn: (orgId: number) => Promise) => { const org = await prisma.org.findUnique({ - where: { - domain, - } - }); - - return !!org; -} - -export const createOrg = async (name: string, domain: string, stripeCustomerId?: string): Promise<{ id: number } | ServiceError> => { - const session = await auth(); - if (!session) { - return notAuthenticated(); - } - - const existingOrg = await prisma.org.findUnique({ where: { domain, }, }); - if (existingOrg) { - return orgDomainExists(); + if (!org) { + return notFound(); } - // Create the org - const org = await prisma.org.create({ - data: { - name, - domain, - stripeCustomerId, - 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({ where: { orgId_userId: { userId: session.user.id, - orgId, + orgId: org.id, } }, }); + if (!membership) { return notFound(); } - // Update the user's active org - await prisma.user.update({ - where: { - id: session.user.id, - }, - data: { - activeOrgId: orgId, - } - }); - - return { - id: orgId, - } + return fn(org.id); } -export const createConnection = async (name: string, type: string, connectionConfig: string): Promise<{ id: number } | ServiceError> => { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return 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 isAuthed = async () => { + const session = await auth(); + return session != null; } -export const updateConnectionDisplayName = async (connectionId: number, name: string): Promise<{ success: boolean } | ServiceError> => { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return orgId; - } +export const createOrg = (name: string, domain: string, stripeCustomerId?: string): Promise<{ id: number } | ServiceError> => + withAuth(async (session) => { + const org = await prisma.org.create({ + data: { + name, + domain, + stripeCustomerId, + members: { + create: { + role: "OWNER", + user: { + connect: { + id: session.user.id, + } + } + } + } + } + }); - 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 { - 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", + id: org.id, } }); - return { - success: true, - } -} +export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => + withAuth((session) => + withOrgMembership(session, domain, async (orgId) => { + const secrets = await prisma.secret.findMany({ + where: { + orgId, + }, + select: { + key: true, + createdAt: true + } + }); -export const deleteConnection = async (connectionId: number): Promise<{ success: boolean } | ServiceError> => { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return orgId; - } + return secrets.map((secret) => ({ + key: secret.key, + createdAt: secret.createdAt, + })); - const connection = await getConnection(connectionId, orgId); - if (!connection) { - return notFound(); - } + })); - await prisma.connection.delete({ - where: { - id: connectionId, - orgId, +export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> => + withAuth((session) => + withOrgMembership(session, domain, async (orgId) => { + try { + const encrypted = encrypt(value); + await prisma.secret.create({ + 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) => { let parsedConfig: ConnectionConfig; try { @@ -326,61 +374,6 @@ const parseConnectionConfig = (connectionType: string, config: string) => { return parsedConfig; } -export const createInvite = async (email: string, userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => { - console.log("Creating invite for", email, userId, orgId); - - try { - await prisma.invite.create({ - data: { - recipientEmail: email, - hostUserId: userId, - orgId, - } - }); - } catch (error) { - console.error("Failed to create invite:", error); - return unexpectedError("Failed to create invite"); - } - - return { - success: true, - } -} - -export const redeemInvite = async (invite: Invite, userId: string): Promise<{ orgId: number } | ServiceError> => { - try { - await prisma.userToOrg.create({ - data: { - userId, - orgId: invite.orgId, - role: "MEMBER", - } - }); - - await prisma.user.update({ - where: { - id: userId, - }, - data: { - activeOrgId: invite.orgId, - } - }); - - await prisma.invite.delete({ - where: { - id: invite.id, - } - }); - - return { - orgId: invite.orgId, - } - } catch (error) { - console.error("Failed to redeem invite:", error); - return unexpectedError("Failed to redeem invite"); - } -} - export async function fetchStripeClientSecret(name: string, domain: string) { const session = await auth(); if (!session) { @@ -490,30 +483,27 @@ export async function fetchStripeSession(sessionId: string) { return stripeSession; } -export async function createCustomerPortalSession() { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return orgId; - } +export const getCustomerPortalSessionLink = async (domain: string): Promise => + withAuth((session) => + withOrgMembership(session, domain, async (orgId) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); + if (!org || !org.stripeCustomerId) { + return notFound(); + } - if (!org || !org.stripeCustomerId) { - return notFound(); - } + const origin = (await headers()).get('origin') + const portalSession = await stripe.billingPortal.sessions.create({ + customer: org.stripeCustomerId as string, + return_url: `${origin}/${domain}/settings/billing`, + }); - const origin = (await headers()).get('origin') - const portalSession = await stripe.billingPortal.sessions.create({ - customer: org.stripeCustomerId as string, - return_url: `${origin}/settings/billing`, - }); - - return portalSession; -} + return portalSession.url; + })); export async function fetchSubscription(orgId: number) { const org = await prisma.org.findUnique({ @@ -537,4 +527,15 @@ export async function fetchSubscription(orgId: number) { } } return subscriptions.data[0]; -} \ No newline at end of file +} + +export const checkIfOrgDomainExists = async (domain: string): Promise => + withAuth(async (session) => { + const org = await prisma.org.findFirst({ + where: { + domain, + } + }); + + return !!org; + }); diff --git a/packages/web/src/app/browse/[...path]/codePreview.tsx b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx similarity index 100% rename from packages/web/src/app/browse/[...path]/codePreview.tsx rename to packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx diff --git a/packages/web/src/app/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx similarity index 90% rename from packages/web/src/app/browse/[...path]/page.tsx rename to packages/web/src/app/[domain]/browse/[...path]/page.tsx index 010efd01..72ebd13c 100644 --- a/packages/web/src/app/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -1,17 +1,18 @@ -import { FileHeader } from "@/app/components/fireHeader"; -import { TopBar } from "@/app/components/topBar"; +import { FileHeader } from "@/app/[domain]/components/fireHeader"; +import { TopBar } from "@/app/[domain]/components/topBar"; import { Separator } from '@/components/ui/separator'; import { getFileSource, listRepositories } from '@/lib/server/searchService'; import { base64Decode, isServiceError } from "@/lib/utils"; import { CodePreview } from "./codePreview"; -import { PageNotFound } from "@/app/components/pageNotFound"; +import { PageNotFound } from "@/app/[domain]/components/pageNotFound"; import { ErrorCode } from "@/lib/errorCodes"; import { LuFileX2, LuBookX } from "react-icons/lu"; -import { getCurrentUserOrg } from "@/auth"; +import { getOrgFromDomain } from "@/data/org"; interface BrowsePageProps { params: { path: string[]; + domain: string; }; } @@ -45,18 +46,14 @@ export default async function BrowsePage({ } })(); - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return ( - <> - Error: {orgId.message} - - ) + const org = await getOrgFromDomain(params.domain); + if (!org) { + return } // @todo (bkellam) : We should probably have a endpoint to fetch repository metadata // given it's name or id. - const reposResponse = await listRepositories(orgId); + const reposResponse = await listRepositories(org.id); if (isServiceError(reposResponse)) { // @todo : proper error handling return ( @@ -81,6 +78,7 @@ export default async function BrowsePage({
{repo && ( @@ -108,7 +106,7 @@ export default async function BrowsePage({ path={path} repoName={repoName} revisionName={revisionName ?? 'HEAD'} - orgId={orgId} + orgId={org.id} /> )}
diff --git a/packages/web/src/app/components/editorContextMenu.tsx b/packages/web/src/app/[domain]/components/editorContextMenu.tsx similarity index 98% rename from packages/web/src/app/components/editorContextMenu.tsx rename to packages/web/src/app/[domain]/components/editorContextMenu.tsx index 3e7b6286..24d45d2a 100644 --- a/packages/web/src/app/components/editorContextMenu.tsx +++ b/packages/web/src/app/[domain]/components/editorContextMenu.tsx @@ -8,7 +8,7 @@ import { autoPlacement, computePosition, offset, shift, VirtualElement } from "@ import { Link2Icon } from "@radix-ui/react-icons"; import { EditorView, SelectionRange } from "@uiw/react-codemirror"; import { useCallback, useEffect, useRef } from "react"; -import { resolveServerPath } from "../api/(client)/client"; +import { resolveServerPath } from "../../api/(client)/client"; interface ContextMenuProps { view: EditorView; diff --git a/packages/web/src/app/components/fireHeader.tsx b/packages/web/src/app/[domain]/components/fireHeader.tsx similarity index 100% rename from packages/web/src/app/components/fireHeader.tsx rename to packages/web/src/app/[domain]/components/fireHeader.tsx diff --git a/packages/web/src/app/components/footer.tsx b/packages/web/src/app/[domain]/components/footer.tsx similarity index 100% rename from packages/web/src/app/components/footer.tsx rename to packages/web/src/app/[domain]/components/footer.tsx diff --git a/packages/web/src/app/components/header.tsx b/packages/web/src/app/[domain]/components/header.tsx similarity index 100% rename from packages/web/src/app/components/header.tsx rename to packages/web/src/app/[domain]/components/header.tsx diff --git a/packages/web/src/app/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx similarity index 85% rename from packages/web/src/app/components/navigationMenu.tsx rename to packages/web/src/app/[domain]/components/navigationMenu.tsx index 17d5cecc..bedaa4b9 100644 --- a/packages/web/src/app/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -3,8 +3,8 @@ import { NavigationMenu as NavigationMenuBase, NavigationMenuItem, NavigationMen import Link from "next/link"; import { Separator } from "@/components/ui/separator"; import Image from "next/image"; -import logoDark from "../../../public/sb_logo_dark_small.png"; -import logoLight from "../../../public/sb_logo_light_small.png"; +import logoDark from "@/public/sb_logo_dark_small.png"; +import logoLight from "@/public/sb_logo_light_small.png"; import { SettingsDropdown } from "./settingsDropdown"; import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons"; import { redirect } from "next/navigation"; @@ -13,14 +13,19 @@ import { OrgSelector } from "./orgSelector"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; -export const NavigationMenu = async () => { +interface NavigationMenuProps { + domain: string; +} +export const NavigationMenu = async ({ + domain, +}: NavigationMenuProps) => { return (
{ /> - + - + Search - + Repositories - + Secrets - + Connections - + Settings diff --git a/packages/web/src/app/components/noOrganizationCard.tsx b/packages/web/src/app/[domain]/components/noOrganizationCard.tsx similarity index 100% rename from packages/web/src/app/components/noOrganizationCard.tsx rename to packages/web/src/app/[domain]/components/noOrganizationCard.tsx diff --git a/packages/web/src/app/components/notFound.tsx b/packages/web/src/app/[domain]/components/notFound.tsx similarity index 100% rename from packages/web/src/app/components/notFound.tsx rename to packages/web/src/app/[domain]/components/notFound.tsx diff --git a/packages/web/src/app/components/orgSelector/index.tsx b/packages/web/src/app/[domain]/components/orgSelector/index.tsx similarity index 58% rename from packages/web/src/app/components/orgSelector/index.tsx rename to packages/web/src/app/[domain]/components/orgSelector/index.tsx index e3a1629b..e4c89908 100644 --- a/packages/web/src/app/components/orgSelector/index.tsx +++ b/packages/web/src/app/[domain]/components/orgSelector/index.tsx @@ -1,20 +1,27 @@ import { auth } from "@/auth"; -import { getUser, getUserOrgs } from "../../../data/user"; +import { getUserOrgs } from "../../../../data/user"; import { OrgSelectorDropdown } from "./orgSelectorDropdown"; +import { prisma } from "@/prisma"; -export const OrgSelector = async () => { +interface OrgSelectorProps { + domain: string; +} + +export const OrgSelector = async ({ + domain, +}: OrgSelectorProps) => { const session = await auth(); if (!session) { return null; } - const user = await getUser(session.user.id); - if (!user) { - return null; - } - const orgs = await getUserOrgs(session.user.id); - const activeOrg = orgs.find((org) => org.id === user.activeOrgId); + const activeOrg = await prisma.org.findUnique({ + where: { + domain, + } + }); + if (!activeOrg) { return null; } @@ -24,6 +31,7 @@ export const OrgSelector = async () => { orgs={orgs.map((org) => ({ name: org.name, id: org.id, + domain: org.domain, }))} activeOrgId={activeOrg.id} /> diff --git a/packages/web/src/app/components/orgSelector/orgIcon.tsx b/packages/web/src/app/[domain]/components/orgSelector/orgIcon.tsx similarity index 100% rename from packages/web/src/app/components/orgSelector/orgIcon.tsx rename to packages/web/src/app/[domain]/components/orgSelector/orgIcon.tsx diff --git a/packages/web/src/app/components/orgSelector/orgSelectorDropdown.tsx b/packages/web/src/app/[domain]/components/orgSelector/orgSelectorDropdown.tsx similarity index 64% rename from packages/web/src/app/components/orgSelector/orgSelectorDropdown.tsx rename to packages/web/src/app/[domain]/components/orgSelector/orgSelectorDropdown.tsx index 7035d962..5659a4ee 100644 --- a/packages/web/src/app/components/orgSelector/orgSelectorDropdown.tsx +++ b/packages/web/src/app/[domain]/components/orgSelector/orgSelectorDropdown.tsx @@ -1,20 +1,18 @@ 'use client'; -import { createOrg, switchActiveOrg } from "@/actions"; import { useToast } from "@/components/hooks/use-toast"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { isServiceError } from "@/lib/utils"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; -import { OrgCreationDialog } from "./orgCreationDialog"; import { OrgIcon } from "./orgIcon"; interface OrgSelectorDropdownProps { orgs: { name: string, + domain: string, id: number, }[], activeOrgId: number, @@ -26,7 +24,6 @@ export const OrgSelectorDropdown = ({ }: OrgSelectorDropdownProps) => { const [searchFilter, setSearchFilter] = useState(""); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isCreateOrgDialogOpen, setIsCreateOrgDialogOpen] = useState(false); const { toast } = useToast(); const router = useRouter(); @@ -39,61 +36,13 @@ export const OrgSelectorDropdown = ({ ]; }, [_orgs, activeOrg, activeOrgId]); - const onSwitchOrg = useCallback((orgId: number, orgName: string) => { - switchActiveOrg(orgId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to switch organization. Reason: ${response.message}`, - }); - } else { - toast({ - description: `✅ Switched to ${orgName}`, - }); - } - - - setIsDropdownOpen(false); - // Necessary to refresh the server component. - router.refresh(); - }); + const onSwitchOrg = useCallback((domain: string, orgName: string) => { + router.push(`/${domain}`); + toast({ + description: `✅ Switched to ${orgName}`, + }); }, [router, toast]); - const onCreateOrg = useCallback((name: string, domain: string) => { - createOrg(name, domain) - .then((response) => { - if (isServiceError(response)) { - throw response; - } - - return switchActiveOrg(response.id); - }) - .then((response) => { - if (isServiceError(response)) { - throw response; - } - - toast({ - description: `✅ Organization '${name}' created successfully.`, - }); - - setIsDropdownOpen(false); - // Necessary to refresh the server component. - router.refresh(); - }) - .catch((error) => { - if (isServiceError(error)) { - toast({ - description: `❌ Failed to create organization. Reason: ${error.message}`, - }); - } - }) - .finally(() => { - setIsCreateOrgDialogOpen(false); - }); - }, [router, toast]); - - return ( /* We need to set `modal=false` to fix a issue with having a dialog menu inside of @@ -144,7 +93,7 @@ export const OrgSelectorDropdown = ({ // Need to include org id to handle duplicates. value={`${org.name}-${org.id}`} className="w-full justify-between py-3 font-medium cursor-pointer" - onSelect={() => onSwitchOrg(org.id, org.name)} + onSelect={() => onSwitchOrg(org.domain, org.name)} >
@@ -159,16 +108,6 @@ export const OrgSelectorDropdown = ({ - {searchFilter.length === 0 && ( - - - onCreateOrg(name, domain)} - /> - - )} ); diff --git a/packages/web/src/app/components/pageNotFound.tsx b/packages/web/src/app/[domain]/components/pageNotFound.tsx similarity index 100% rename from packages/web/src/app/components/pageNotFound.tsx rename to packages/web/src/app/[domain]/components/pageNotFound.tsx diff --git a/packages/web/src/app/components/payWall/checkoutButton.tsx b/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx similarity index 100% rename from packages/web/src/app/components/payWall/checkoutButton.tsx rename to packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx diff --git a/packages/web/src/app/components/payWall/enterpriseContactUsButton.tsx b/packages/web/src/app/[domain]/components/payWall/enterpriseContactUsButton.tsx similarity index 100% rename from packages/web/src/app/components/payWall/enterpriseContactUsButton.tsx rename to packages/web/src/app/[domain]/components/payWall/enterpriseContactUsButton.tsx diff --git a/packages/web/src/app/components/payWall/paywallCard.tsx b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx similarity index 100% rename from packages/web/src/app/components/payWall/paywallCard.tsx rename to packages/web/src/app/[domain]/components/payWall/paywallCard.tsx diff --git a/packages/web/src/app/components/repositoryCarousel.tsx b/packages/web/src/app/[domain]/components/repositoryCarousel.tsx similarity index 100% rename from packages/web/src/app/components/repositoryCarousel.tsx rename to packages/web/src/app/[domain]/components/repositoryCarousel.tsx diff --git a/packages/web/src/app/components/searchBar/constants.ts b/packages/web/src/app/[domain]/components/searchBar/constants.ts similarity index 100% rename from packages/web/src/app/components/searchBar/constants.ts rename to packages/web/src/app/[domain]/components/searchBar/constants.ts diff --git a/packages/web/src/app/components/searchBar/index.ts b/packages/web/src/app/[domain]/components/searchBar/index.ts similarity index 100% rename from packages/web/src/app/components/searchBar/index.ts rename to packages/web/src/app/[domain]/components/searchBar/index.ts diff --git a/packages/web/src/app/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx similarity index 98% rename from packages/web/src/app/components/searchBar/searchBar.tsx rename to packages/web/src/app/[domain]/components/searchBar/searchBar.tsx index 964b5ff7..d038d905 100644 --- a/packages/web/src/app/components/searchBar/searchBar.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx @@ -42,6 +42,7 @@ import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Toggle } from "@/components/ui/toggle"; +import { useDomain } from "@/hooks/useDomain"; interface SearchBarProps { className?: string; @@ -92,6 +93,7 @@ export const SearchBar = ({ autoFocus, }: SearchBarProps) => { const router = useRouter(); + const domain = useDomain(); const tailwind = useTailwind(); const suggestionBoxRef = useRef(null); const editorRef = useRef(null); @@ -202,11 +204,11 @@ export const SearchBar = ({ setIsSuggestionsEnabled(false); setIsHistorySearchEnabled(false); - const url = createPathWithQueryParams('/search', + const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, query], ); router.push(url); - }, [router]); + }, [domain, router]); return (
{ + const domain = useDomain(); const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({ queryKey: ["repoSuggestions"], - queryFn: getRepos, + queryFn: () => getRepos(domain), select: (data): Suggestion[] => { return data.List.Repos .map(r => r.Repository) @@ -52,7 +54,7 @@ export const useSuggestionsData = ({ queryFn: () => search({ query: `file:${suggestionQuery}`, maxMatchDisplayCount: 15, - }), + }, domain), select: (data): Suggestion[] => { return data.Result.Files?.map((file) => ({ value: file.FileName @@ -67,7 +69,7 @@ export const useSuggestionsData = ({ queryFn: () => search({ query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`, maxMatchDisplayCount: 15, - }), + }, domain), select: (data): Suggestion[] => { const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []); if (!symbols) { diff --git a/packages/web/src/app/components/searchBar/zoektLanguageExtension.ts b/packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts similarity index 100% rename from packages/web/src/app/components/searchBar/zoektLanguageExtension.ts rename to packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts diff --git a/packages/web/src/app/components/settingsDropdown.tsx b/packages/web/src/app/[domain]/components/settingsDropdown.tsx similarity index 97% rename from packages/web/src/app/components/settingsDropdown.tsx rename to packages/web/src/app/[domain]/components/settingsDropdown.tsx index dcc85580..a1cc04a8 100644 --- a/packages/web/src/app/components/settingsDropdown.tsx +++ b/packages/web/src/app/[domain]/components/settingsDropdown.tsx @@ -86,7 +86,9 @@ export const SettingsDropdown = ({
{ - signOut(); + signOut({ + redirectTo: "/login", + }); }} > diff --git a/packages/web/src/app/components/topBar.tsx b/packages/web/src/app/[domain]/components/topBar.tsx similarity index 93% rename from packages/web/src/app/components/topBar.tsx rename to packages/web/src/app/[domain]/components/topBar.tsx index 3ca9bd10..351eb60b 100644 --- a/packages/web/src/app/components/topBar.tsx +++ b/packages/web/src/app/[domain]/components/topBar.tsx @@ -7,16 +7,18 @@ import { SettingsDropdown } from "./settingsDropdown"; interface TopBarProps { defaultSearchQuery?: string; + domain: string; } export const TopBar = ({ - defaultSearchQuery + defaultSearchQuery, + domain, }: TopBarProps) => { return (
({ }) { const { toast } = useToast(); const router = useRouter(); + const domain = useDomain(); const formSchema = useMemo(() => { return z.object({ config: createZodConnectionConfigValidator(schema), @@ -77,7 +79,7 @@ function ConfigSettingInternal({ const [isLoading, setIsLoading] = useState(false); const onSubmit = useCallback((data: z.infer) => { setIsLoading(true); - updateConnectionConfigAndScheduleSync(connectionId, data.config) + updateConnectionConfigAndScheduleSync(connectionId, data.config, domain) .then((response) => { if (isServiceError(response)) { toast({ @@ -94,7 +96,7 @@ function ConfigSettingInternal({ .finally(() => { setIsLoading(false); }) - }, [connectionId, router, toast]); + }, [connectionId, domain, router, toast]); return (
diff --git a/packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx b/packages/web/src/app/[domain]/connections/[id]/components/deleteConnectionSetting.tsx similarity index 93% rename from packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx rename to packages/web/src/app/[domain]/connections/[id]/components/deleteConnectionSetting.tsx index cce8f399..2b04aa08 100644 --- a/packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/deleteConnectionSetting.tsx @@ -18,6 +18,7 @@ import { Loader2 } from "lucide-react"; import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; +import { useDomain } from "@/hooks/useDomain"; interface DeleteConnectionSettingProps { connectionId: number; @@ -28,13 +29,14 @@ export const DeleteConnectionSetting = ({ }: DeleteConnectionSettingProps) => { const [isDialogOpen, setIsDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); + const domain = useDomain(); const { toast } = useToast(); const router = useRouter(); const handleDelete = useCallback(() => { setIsDialogOpen(false); setIsLoading(true); - deleteConnection(connectionId) + deleteConnection(connectionId, domain) .then((response) => { if (isServiceError(response)) { toast({ @@ -44,14 +46,14 @@ export const DeleteConnectionSetting = ({ toast({ description: `✅ Connection deleted successfully.` }); - router.replace("/connections"); + router.replace(`/${domain}/connections`); router.refresh(); } }) .finally(() => { setIsLoading(false); }); - }, [connectionId]); + }, [connectionId, domain, router, toast]); return (
diff --git a/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx b/packages/web/src/app/[domain]/connections/[id]/components/displayNameSetting.tsx similarity index 94% rename from packages/web/src/app/connections/[id]/components/displayNameSetting.tsx rename to packages/web/src/app/[domain]/connections/[id]/components/displayNameSetting.tsx index 26918d42..95a9ea58 100644 --- a/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/displayNameSetting.tsx @@ -5,6 +5,7 @@ import { useToast } from "@/components/hooks/use-toast"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { useDomain } from "@/hooks/useDomain"; import { isServiceError } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { Loader2 } from "lucide-react"; @@ -28,6 +29,7 @@ export const DisplayNameSetting = ({ }: DisplayNameSettingProps) => { const { toast } = useToast(); const router = useRouter(); + const domain = useDomain(); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -38,7 +40,7 @@ export const DisplayNameSetting = ({ const [isLoading, setIsLoading] = useState(false); const onSubmit = useCallback((data: z.infer) => { setIsLoading(true); - updateConnectionDisplayName(connectionId, data.name) + updateConnectionDisplayName(connectionId, data.name, domain) .then((response) => { if (isServiceError(response)) { toast({ @@ -53,7 +55,7 @@ export const DisplayNameSetting = ({ }).finally(() => { setIsLoading(false); }); - }, [connectionId, router, toast]); + }, [connectionId, domain, router, toast]); return (
diff --git a/packages/web/src/app/connections/[id]/components/repoListItem.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx similarity index 100% rename from packages/web/src/app/connections/[id]/components/repoListItem.tsx rename to packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx diff --git a/packages/web/src/app/connections/[id]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx similarity index 92% rename from packages/web/src/app/connections/[id]/page.tsx rename to packages/web/src/app/[domain]/connections/[id]/page.tsx index 59f08131..bf5584bd 100644 --- a/packages/web/src/app/connections/[id]/page.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx @@ -1,5 +1,4 @@ -import { NotFound } from "@/app/components/notFound"; -import { getCurrentUserOrg } from "@/auth"; +import { NotFound } from "@/app/[domain]/components/notFound"; import { Breadcrumb, BreadcrumbItem, @@ -12,17 +11,19 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { TabSwitcher } from "@/components/ui/tab-switcher"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { getConnection, getLinkedRepos } from "@/data/connection"; -import { isServiceError } from "@/lib/utils"; import { ConnectionIcon } from "../components/connectionIcon"; import { Header } from "../../components/header"; import { ConfigSetting } from "./components/configSetting"; import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"; import { DisplayNameSetting } from "./components/displayNameSetting"; import { RepoListItem } from "./components/repoListItem"; +import { getOrgFromDomain } from "@/data/org"; +import { PageNotFound } from "../../components/pageNotFound"; interface ConnectionManagementPageProps { params: { id: string; + domain: string; }, searchParams: { tab?: string; @@ -33,13 +34,9 @@ export default async function ConnectionManagementPage({ params, searchParams, }: ConnectionManagementPageProps) { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return ( - <> - Error: {orgId.message} - - ) + const org = await getOrgFromDomain(params.domain); + if (!org) { + return } const connectionId = Number(params.id); @@ -52,7 +49,7 @@ export default async function ConnectionManagementPage({ ) } - const connection = await getConnection(Number(params.id), orgId); + const connection = await getConnection(Number(params.id), org.id); if (!connection) { return ( - Connections + Connections diff --git a/packages/web/src/app/connections/components/configEditor.tsx b/packages/web/src/app/[domain]/connections/components/configEditor.tsx similarity index 100% rename from packages/web/src/app/connections/components/configEditor.tsx rename to packages/web/src/app/[domain]/connections/components/configEditor.tsx diff --git a/packages/web/src/app/connections/components/connectionIcon.tsx b/packages/web/src/app/[domain]/connections/components/connectionIcon.tsx similarity index 100% rename from packages/web/src/app/connections/components/connectionIcon.tsx rename to packages/web/src/app/[domain]/connections/components/connectionIcon.tsx diff --git a/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx b/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItem.tsx similarity index 98% rename from packages/web/src/app/connections/components/connectionList/connectionListItem.tsx rename to packages/web/src/app/[domain]/connections/components/connectionList/connectionListItem.tsx index 0d4dfc22..d217b9b0 100644 --- a/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx +++ b/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItem.tsx @@ -53,7 +53,7 @@ export const ConnectionListItem = ({ }, [status]); return ( - +
diff --git a/packages/web/src/app/connections/components/connectionList/index.tsx b/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx similarity index 84% rename from packages/web/src/app/connections/components/connectionList/index.tsx rename to packages/web/src/app/[domain]/connections/components/connectionList/index.tsx index 841c833d..2f6936ab 100644 --- a/packages/web/src/app/connections/components/connectionList/index.tsx +++ b/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx @@ -1,11 +1,18 @@ -import { Connection } from "@sourcebot/db" import { ConnectionListItem } from "./connectionListItem"; import { cn } from "@/lib/utils"; import { InfoCircledIcon } from "@radix-ui/react-icons"; +import { ConnectionSyncStatus } from "@sourcebot/db"; interface ConnectionListProps { - connections: Connection[]; + connections: { + id: number, + name: string, + connectionType: string, + syncStatus: ConnectionSyncStatus, + updatedAt: Date, + syncedAt?: Date + }[]; className?: string; } diff --git a/packages/web/src/app/connections/components/newConnectionCard.tsx b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx similarity index 98% rename from packages/web/src/app/connections/components/newConnectionCard.tsx rename to packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx index fd6351fd..ae54bd1b 100644 --- a/packages/web/src/app/connections/components/newConnectionCard.tsx +++ b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx @@ -78,7 +78,7 @@ const Card = ({ return (
{Icon} diff --git a/packages/web/src/app/connections/components/statusIcon.tsx b/packages/web/src/app/[domain]/connections/components/statusIcon.tsx similarity index 100% rename from packages/web/src/app/connections/components/statusIcon.tsx rename to packages/web/src/app/[domain]/connections/components/statusIcon.tsx diff --git a/packages/web/src/app/connections/layout.tsx b/packages/web/src/app/[domain]/connections/layout.tsx similarity index 81% rename from packages/web/src/app/connections/layout.tsx rename to packages/web/src/app/[domain]/connections/layout.tsx index 2877c918..9dc10734 100644 --- a/packages/web/src/app/connections/layout.tsx +++ b/packages/web/src/app/[domain]/connections/layout.tsx @@ -2,13 +2,15 @@ import { NavigationMenu } from "../components/navigationMenu"; export default function Layout({ children, + params: { domain }, }: Readonly<{ children: React.ReactNode; + params: { domain: string }; }>) { return (
- +
{children}
diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx b/packages/web/src/app/[domain]/connections/new/[type]/components/connectionCreationForm.tsx similarity index 92% rename from packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx rename to packages/web/src/app/[domain]/connections/new/[type]/components/connectionCreationForm.tsx index 9f9f2fe4..02612fea 100644 --- a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/connections/new/[type]/components/connectionCreationForm.tsx @@ -2,8 +2,8 @@ 'use client'; import { createConnection } from "@/actions"; -import { ConnectionIcon } from "@/app/connections/components/connectionIcon"; -import { createZodConnectionConfigValidator } from "@/app/connections/utils"; +import { ConnectionIcon } from "@/app/[domain]/connections/components/connectionIcon"; +import { createZodConnectionConfigValidator } from "@/app/[domain]/connections/utils"; import { useToast } from "@/components/hooks/use-toast"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; @@ -16,6 +16,7 @@ import { useCallback, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { ConfigEditor, QuickActionFn } from "../../../components/configEditor"; +import { useDomain } from "@/hooks/useDomain"; interface ConnectionCreationForm { type: 'github' | 'gitlab'; @@ -41,6 +42,7 @@ export default function ConnectionCreationForm({ const { toast } = useToast(); const router = useRouter(); + const domain = useDomain(); const formSchema = useMemo(() => { return z.object({ @@ -55,7 +57,7 @@ export default function ConnectionCreationForm({ }); const onSubmit = useCallback((data: z.infer) => { - createConnection(data.name, type, data.config) + createConnection(data.name, type, data.config, domain) .then((response) => { if (isServiceError(response)) { toast({ @@ -65,11 +67,11 @@ export default function ConnectionCreationForm({ toast({ description: `✅ Connection created successfully.` }); - router.push('/connections'); + router.push(`/${domain}/connections`); router.refresh(); } }); - }, [router, toast, type]); + }, [domain, router, toast, type]); return (
diff --git a/packages/web/src/app/connections/new/[type]/page.tsx b/packages/web/src/app/[domain]/connections/new/[type]/page.tsx similarity index 100% rename from packages/web/src/app/connections/new/[type]/page.tsx rename to packages/web/src/app/[domain]/connections/new/[type]/page.tsx diff --git a/packages/web/src/app/connections/page.tsx b/packages/web/src/app/[domain]/connections/page.tsx similarity index 57% rename from packages/web/src/app/connections/page.tsx rename to packages/web/src/app/[domain]/connections/page.tsx index 005c595b..f0e9c113 100644 --- a/packages/web/src/app/connections/page.tsx +++ b/packages/web/src/app/[domain]/connections/page.tsx @@ -1,27 +1,16 @@ -import { auth } from "@/auth"; -import { getUser } from "@/data/user"; -import { prisma } from "@/prisma"; import { ConnectionList } from "./components/connectionList"; import { Header } from "../components/header"; import { NewConnectionCard } from "./components/newConnectionCard"; +import NotFoundPage from "@/app/not-found"; +import { getConnections } from "@/actions"; +import { isServiceError } from "@/lib/utils"; -export default async function ConnectionsPage() { - const session = await auth(); - if (!session) { - return null; +export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) { + const connections = await getConnections(domain); + if (isServiceError(connections)) { + return ; } - const user = await getUser(session.user.id); - if (!user || !user.activeOrgId) { - return null; - } - - const connections = await prisma.connection.findMany({ - where: { - orgId: user.activeOrgId, - } - }); - return (
diff --git a/packages/web/src/app/connections/quickActions.ts b/packages/web/src/app/[domain]/connections/quickActions.ts similarity index 100% rename from packages/web/src/app/connections/quickActions.ts rename to packages/web/src/app/[domain]/connections/quickActions.ts diff --git a/packages/web/src/app/connections/utils.ts b/packages/web/src/app/[domain]/connections/utils.ts similarity index 100% rename from packages/web/src/app/connections/utils.ts rename to packages/web/src/app/[domain]/connections/utils.ts diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx new file mode 100644 index 00000000..25f7f97c --- /dev/null +++ b/packages/web/src/app/[domain]/layout.tsx @@ -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 + } + + + const session = await auth(); + if (!session) { + return + } + + + const membership = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId: org.id, + userId: session.user.id + } + } + }); + + if (!membership) { + return + } + + return children; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx new file mode 100644 index 00000000..0d0244c5 --- /dev/null +++ b/packages/web/src/app/[domain]/page.tsx @@ -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 + } + + return ( +
+ + +
+
+ {"Sourcebot + {"Sourcebot +
+ +
+ ...
}> + + +
+
+ + How to search +
+ + + test todo (both test and todo) + + + test or todo (either test or todo) + + + {`"exit boot"`} (exact match) + + + TODO case:yes (case sensitive) + + + + + file:README setup (by filename) + + + repo:torvalds/linux test (by repo) + + + lang:typescript (by language) + + + rev:HEAD (by branch or tag) + + + + + file:{`\\.py$`} {`(files that end in ".py")`} + + + sym:main {`(symbols named "main")`} + + + todo -lang:c (negate filter) + + + content:README (search content only) + + +
+
+
+
+ About + + Support + + Contact Us +
+
+ ) +} + +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 ( +
+ + indexing in progress... +
+ ) + } + + return ( +
+ + {`Search ${repos.length} `} + + {repos.length > 1 ? 'repositories' : 'repository'} + + + +
+ ) +} + +const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => { + return ( +
+ {title} + {children} +
+ ) + +} + +const Highlight = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} + +const QueryExample = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} + +const QueryExplanation = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} + +const Query = ({ query, children }: { query: string, children: React.ReactNode }) => { + return ( + + {children} + + ) +} diff --git a/packages/web/src/app/repos/columns.tsx b/packages/web/src/app/[domain]/repos/columns.tsx similarity index 100% rename from packages/web/src/app/repos/columns.tsx rename to packages/web/src/app/[domain]/repos/columns.tsx diff --git a/packages/web/src/app/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx similarity index 50% rename from packages/web/src/app/repos/page.tsx rename to packages/web/src/app/[domain]/repos/page.tsx index 3bcd39c7..917c521a 100644 --- a/packages/web/src/app/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -1,25 +1,21 @@ import { Suspense } from "react"; import { NavigationMenu } from "../components/navigationMenu"; import { RepositoryTable } from "./repositoryTable"; -import { getCurrentUserOrg } from "@/auth"; -import { isServiceError } from "@/lib/utils"; +import { getOrgFromDomain } from "@/data/org"; +import { PageNotFound } from "../components/pageNotFound"; -export default async function ReposPage() { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return ( - <> - Error: {orgId.message} - - ) +export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) { + const org = await getOrgFromDomain(domain); + if (!org) { + return } return (
- + Loading...
}>
- +
diff --git a/packages/web/src/app/repos/repositoryTable.tsx b/packages/web/src/app/[domain]/repos/repositoryTable.tsx similarity index 100% rename from packages/web/src/app/repos/repositoryTable.tsx rename to packages/web/src/app/[domain]/repos/repositoryTable.tsx diff --git a/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx similarity index 98% rename from packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx rename to packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx index 10de7a71..983b3706 100644 --- a/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx @@ -1,6 +1,6 @@ 'use client'; -import { EditorContextMenu } from "@/app/components/editorContextMenu"; +import { EditorContextMenu } from "@/app/[domain]/components/editorContextMenu"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useKeymapExtension } from "@/hooks/useKeymapExtension"; diff --git a/packages/web/src/app/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx similarity index 96% rename from packages/web/src/app/search/components/codePreviewPanel/index.tsx rename to packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx index 22a0332f..a82b70a4 100644 --- a/packages/web/src/app/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx @@ -5,6 +5,7 @@ import { base64Decode } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { CodePreview, CodePreviewFile } from "./codePreview"; import { SearchResultFile } from "@/lib/types"; +import { useDomain } from "@/hooks/useDomain"; interface CodePreviewPanelProps { fileMatch?: SearchResultFile; @@ -21,6 +22,7 @@ export const CodePreviewPanel = ({ onSelectedMatchIndexChange, repoUrlTemplates, }: CodePreviewPanelProps) => { + const domain = useDomain(); const { data: file } = useQuery({ queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository, fileMatch?.Branches], @@ -37,7 +39,7 @@ export const CodePreviewPanel = ({ fileName: fileMatch.FileName, repository: fileMatch.Repository, branch, - }) + }, domain) .then(({ source }) => { const link = (() => { const template = repoUrlTemplates[fileMatch.Repository]; diff --git a/packages/web/src/app/search/components/filterPanel/entry.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx similarity index 100% rename from packages/web/src/app/search/components/filterPanel/entry.tsx rename to packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx diff --git a/packages/web/src/app/search/components/filterPanel/filter.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/filter.tsx similarity index 100% rename from packages/web/src/app/search/components/filterPanel/filter.tsx rename to packages/web/src/app/[domain]/search/components/filterPanel/filter.tsx diff --git a/packages/web/src/app/search/components/filterPanel/index.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx similarity index 100% rename from packages/web/src/app/search/components/filterPanel/index.tsx rename to packages/web/src/app/[domain]/search/components/filterPanel/index.tsx diff --git a/packages/web/src/app/search/components/searchResultsPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx similarity index 100% rename from packages/web/src/app/search/components/searchResultsPanel/codePreview.tsx rename to packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx diff --git a/packages/web/src/app/search/components/searchResultsPanel/fileMatch.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx similarity index 100% rename from packages/web/src/app/search/components/searchResultsPanel/fileMatch.tsx rename to packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx diff --git a/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx similarity index 98% rename from packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx rename to packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx index 496e9f3b..539c19b8 100644 --- a/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FileHeader } from "@/app/components/fireHeader"; +import { FileHeader } from "@/app/[domain]/components/fireHeader"; import { Separator } from "@/components/ui/separator"; import { Repository, SearchResultFile } from "@/lib/types"; import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons"; diff --git a/packages/web/src/app/search/components/searchResultsPanel/index.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx similarity index 100% rename from packages/web/src/app/search/components/searchResultsPanel/index.tsx rename to packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx diff --git a/packages/web/src/app/search/components/searchResultsPanel/lightweightCodeMirror.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx similarity index 100% rename from packages/web/src/app/search/components/searchResultsPanel/lightweightCodeMirror.tsx rename to packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx diff --git a/packages/web/src/app/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx similarity index 97% rename from packages/web/src/app/search/page.tsx rename to packages/web/src/app/[domain]/search/page.tsx index f8095ebe..3c9ed729 100644 --- a/packages/web/src/app/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -16,11 +16,12 @@ import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ImperativePanelHandle } from "react-resizable-panels"; -import { getRepos, search } from "../api/(client)/client"; +import { getRepos, search } from "../../api/(client)/client"; import { TopBar } from "../components/topBar"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; +import { useDomain } from "@/hooks/useDomain"; const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; @@ -42,13 +43,14 @@ const SearchPageInternal = () => { const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount; const { setSearchHistory } = useSearchHistory(); const captureEvent = useCaptureEvent(); + const domain = useDomain(); const { data: searchResponse, isLoading } = useQuery({ queryKey: ["search", searchQuery, maxMatchDisplayCount], queryFn: () => search({ query: searchQuery, maxMatchDisplayCount, - }), + }, domain), enabled: searchQuery.length > 0, refetchOnWindowFocus: false, }); @@ -75,7 +77,7 @@ const SearchPageInternal = () => { // for easy lookup. const { data: repoMetadata } = useQuery({ queryKey: ["repos"], - queryFn: () => getRepos(), + queryFn: () => getRepos(domain), select: (data): Record => data.List.Repos .map(r => r.Repository) @@ -185,7 +187,10 @@ const SearchPageInternal = () => {
{/* TopBar */}
- + {!isLoading && (
diff --git a/packages/web/src/app/secrets/columns.tsx b/packages/web/src/app/[domain]/secrets/columns.tsx similarity index 100% rename from packages/web/src/app/secrets/columns.tsx rename to packages/web/src/app/[domain]/secrets/columns.tsx diff --git a/packages/web/src/app/secrets/page.tsx b/packages/web/src/app/[domain]/secrets/page.tsx similarity index 53% rename from packages/web/src/app/secrets/page.tsx rename to packages/web/src/app/[domain]/secrets/page.tsx index 168d553d..c3d6d45d 100644 --- a/packages/web/src/app/secrets/page.tsx +++ b/packages/web/src/app/[domain]/secrets/page.tsx @@ -1,21 +1,19 @@ import { NavigationMenu } from "../components/navigationMenu"; import { SecretsTable } from "./secretsTable"; -import { getSecrets } from "../../actions" import { isServiceError } from "@/lib/utils"; +import { getSecrets } from "@/actions"; -export interface SecretsTableProps { - initialSecrets: { createdAt: Date; key: string; }[]; -} - -export default async function SecretsPage() { - const secrets = await getSecrets(); +export default async function SecretsPage({ params: { domain } }: { params: { domain: string } }) { + const secrets = await getSecrets(domain); return (
- + { !isServiceError(secrets) && (
- +
)}
diff --git a/packages/web/src/app/secrets/secretsTable.tsx b/packages/web/src/app/[domain]/secrets/secretsTable.tsx similarity index 88% rename from packages/web/src/app/secrets/secretsTable.tsx rename to packages/web/src/app/[domain]/secrets/secretsTable.tsx index 94623bf5..e37e5702 100644 --- a/packages/web/src/app/secrets/secretsTable.tsx +++ b/packages/web/src/app/[domain]/secrets/secretsTable.tsx @@ -1,6 +1,7 @@ 'use client'; + import { useEffect, useMemo, useState } from "react"; -import { getSecrets, createSecret } from "../../actions" +import { getSecrets, createSecret } from "../../../actions" import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; @@ -11,21 +12,26 @@ import { columns, SecretColumnInfo } from "./columns"; import { DataTable } from "@/components/ui/data-table"; import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; -import { deleteSecret } from "../../actions" -import { SecretsTableProps } from "./page"; +import { deleteSecret } from "../../../actions" +import { useDomain } from "@/hooks/useDomain"; const formSchema = z.object({ key: z.string().min(2).max(40), value: z.string().min(2).max(40), }); +interface SecretsTableProps { + initialSecrets: { createdAt: Date; key: string; }[]; +} + export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => { const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets); const { toast } = useToast(); + const domain = useDomain(); const fetchSecretKeys = async () => { - const keys = await getSecrets(); + const keys = await getSecrets(domain); if ('keys' in keys) { setSecrets(keys); } else { @@ -46,7 +52,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => { }); const handleCreateSecret = async (values: { key: string, value: string }) => { - const res = await createSecret(values.key, values.value); + const res = await createSecret(values.key, values.value, domain); if (isServiceError(res)) { toast({ description: `❌ Failed to create secret` @@ -58,7 +64,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => { }); } - const keys = await getSecrets(); + const keys = await getSecrets(domain); if (isServiceError(keys)) { console.error("Failed to fetch secrets"); } else { @@ -71,7 +77,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => { }; const handleDelete = async (key: string) => { - const res = await deleteSecret(key); + const res = await deleteSecret(key, domain); if (isServiceError(res)) { toast({ description: `❌ Failed to delete secret` @@ -83,7 +89,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => { }); } - const keys = await getSecrets(); + const keys = await getSecrets(domain); if ('keys' in keys) { setSecrets(keys); } else { diff --git a/packages/web/src/app/settings/billing/manageSubscriptionButton.tsx b/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx similarity index 78% rename from packages/web/src/app/settings/billing/manageSubscriptionButton.tsx rename to packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx index bfb2de60..dec774de 100644 --- a/packages/web/src/app/settings/billing/manageSubscriptionButton.tsx +++ b/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx @@ -4,20 +4,22 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { isServiceError } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { createCustomerPortalSession } from "../../../actions" +import { getCustomerPortalSessionLink } from "@/actions" +import { useDomain } from "@/hooks/useDomain"; export function ManageSubscriptionButton() { const [isLoading, setIsLoading] = useState(false) const router = useRouter() + const domain = useDomain(); const redirectToCustomerPortal = async () => { setIsLoading(true) try { - const session = await createCustomerPortalSession() + const session = await getCustomerPortalSessionLink(domain) if (isServiceError(session)) { console.log("Failed to create portal session: ", session) } else { - router.push(session.url!) + router.push(session) } } catch (error) { console.error("Error creating portal session:", error) diff --git a/packages/web/src/app/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx similarity index 100% rename from packages/web/src/app/settings/billing/page.tsx rename to packages/web/src/app/[domain]/settings/billing/page.tsx diff --git a/packages/web/src/app/settings/components/inviteTable.tsx b/packages/web/src/app/[domain]/settings/components/inviteTable.tsx similarity index 100% rename from packages/web/src/app/settings/components/inviteTable.tsx rename to packages/web/src/app/[domain]/settings/components/inviteTable.tsx diff --git a/packages/web/src/app/settings/components/inviteTableColumns.tsx b/packages/web/src/app/[domain]/settings/components/inviteTableColumns.tsx similarity index 95% rename from packages/web/src/app/settings/components/inviteTableColumns.tsx rename to packages/web/src/app/[domain]/settings/components/inviteTableColumns.tsx index fae160f4..545c158d 100644 --- a/packages/web/src/app/settings/components/inviteTableColumns.tsx +++ b/packages/web/src/app/[domain]/settings/components/inviteTableColumns.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { ColumnDef } from "@tanstack/react-table" -import { resolveServerPath } from "../../api/(client)/client"; +import { resolveServerPath } from "@/app/api/(client)/client"; import { createPathWithQueryParams } from "@/lib/utils"; export type InviteColumnInfo = { diff --git a/packages/web/src/app/settings/components/memberInviteForm.tsx b/packages/web/src/app/[domain]/settings/components/memberInviteForm.tsx similarity index 88% rename from packages/web/src/app/settings/components/memberInviteForm.tsx rename to packages/web/src/app/[domain]/settings/components/memberInviteForm.tsx index e7fcdda0..cb5d47ed 100644 --- a/packages/web/src/app/settings/components/memberInviteForm.tsx +++ b/packages/web/src/app/[domain]/settings/components/memberInviteForm.tsx @@ -6,15 +6,17 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useToast } from "@/components/hooks/use-toast"; -import { createInvite } from "../../../actions" +import { createInvite } from "@/actions" import { isServiceError } from "@/lib/utils"; +import { useDomain } from "@/hooks/useDomain"; const formSchema = z.object({ email: z.string().min(2).max(40), }); -export const MemberInviteForm = ({ orgId, userId }: { orgId: number, userId: string }) => { +export const MemberInviteForm = ({ userId }: { userId: string }) => { const { toast } = useToast(); + const domain = useDomain(); const form = useForm>({ resolver: zodResolver(formSchema), @@ -24,7 +26,7 @@ export const MemberInviteForm = ({ orgId, userId }: { orgId: number, userId: str }); const handleCreateInvite = async (values: { email: string }) => { - const res = await createInvite(values.email, userId, orgId); + const res = await createInvite(values.email, userId, domain); if (isServiceError(res)) { toast({ description: `❌ Failed to create invite` diff --git a/packages/web/src/app/settings/components/memberTable.tsx b/packages/web/src/app/[domain]/settings/components/memberTable.tsx similarity index 100% rename from packages/web/src/app/settings/components/memberTable.tsx rename to packages/web/src/app/[domain]/settings/components/memberTable.tsx diff --git a/packages/web/src/app/settings/components/memberTableColumns.tsx b/packages/web/src/app/[domain]/settings/components/memberTableColumns.tsx similarity index 100% rename from packages/web/src/app/settings/components/memberTableColumns.tsx rename to packages/web/src/app/[domain]/settings/components/memberTableColumns.tsx diff --git a/packages/web/src/app/settings/components/sidebar-nav.tsx b/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx similarity index 100% rename from packages/web/src/app/settings/components/sidebar-nav.tsx rename to packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx new file mode 100644 index 00000000..400097ae --- /dev/null +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -0,0 +1,66 @@ +import { Metadata } from "next" +import Image from "next/image" + +import { Separator } from "@/components/ui/separator" +import { SidebarNav } from "./components/sidebar-nav" +import { NavigationMenu } from "../components/navigationMenu" + +export const metadata: Metadata = { + title: "Settings", +} + +export default function SettingsLayout({ + children, + params: { domain }, +}: Readonly<{ + children: React.ReactNode; + params: { domain: string }; +}>) { + const sidebarNavItems = [ + { + title: "Members", + href: `/${domain}/settings`, + }, + { + title: "Billing", + href: `/${domain}/settings/billing`, + } + ] + + return ( + <> +
+ Forms + Forms +
+ +
+
+

Settings

+

+ Manage your organization settings. +

+
+ +
+ +
{children}
+
+
+ + ) +} \ No newline at end of file diff --git a/packages/web/src/app/settings/page.tsx b/packages/web/src/app/[domain]/settings/page.tsx similarity index 75% rename from packages/web/src/app/settings/page.tsx rename to packages/web/src/app/[domain]/settings/page.tsx index 7c1b4036..c1439c7d 100644 --- a/packages/web/src/app/settings/page.tsx +++ b/packages/web/src/app/[domain]/settings/page.tsx @@ -6,7 +6,15 @@ import { MemberInviteForm } from "./components/memberInviteForm"; import { InviteTable } from "./components/inviteTable"; import { Separator } from "@/components/ui/separator" -export default async function MembersPage() { +interface SettingsPageProps { + params: { + domain: string; + }; +} + +export default async function SettingsPage({ + params: { domain } +}: SettingsPageProps) { const fetchData = async () => { const session = await auth(); if (!session) { @@ -14,7 +22,17 @@ export default async function MembersPage() { } const user = await getUser(session.user.id); - if (!user || !user.activeOrgId) { + if (!user) { + return null; + } + + const activeOrg = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!activeOrg) { return null; } @@ -22,14 +40,14 @@ export default async function MembersPage() { where: { orgs: { some: { - orgId: user.activeOrgId, + orgId: activeOrg.id, }, }, }, include: { orgs: { where: { - orgId: user.activeOrgId, + orgId: activeOrg.id, }, select: { role: true, @@ -40,7 +58,7 @@ export default async function MembersPage() { const invites = await prisma.invite.findMany({ where: { - orgId: user.activeOrgId, + orgId: activeOrg.id, }, }); @@ -55,7 +73,12 @@ export default async function MembersPage() { createdAt: invite.createdAt, })); - return { user, memberInfo, inviteInfo }; + return { + user, + memberInfo, + inviteInfo, + activeOrg, + }; }; const data = await fetchData(); @@ -76,7 +99,7 @@ export default async function MembersPage() {
- +
diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 889ecd42..ac83d370 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -5,12 +5,13 @@ import { fileSourceResponseSchema, listRepositoriesResponseSchema, searchRespons import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types"; import assert from "assert"; -export const search = async (body: SearchRequest): Promise => { +export const search = async (body: SearchRequest, domain: string): Promise => { const path = resolveServerPath("/api/search"); const result = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json", + "X-Org-Domain": domain, }, body: JSON.stringify(body), }).then(response => response.json()); @@ -18,12 +19,13 @@ export const search = async (body: SearchRequest): Promise => { return searchResponseSchema.parse(result); } -export const fetchFileSource = async (body: FileSourceRequest): Promise => { +export const fetchFileSource = async (body: FileSourceRequest, domain: string): Promise => { const path = resolveServerPath("/api/source"); const result = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json", + "X-Org-Domain": domain, }, body: JSON.stringify(body), }).then(response => response.json()); @@ -31,12 +33,13 @@ export const fetchFileSource = async (body: FileSourceRequest): Promise => { +export const getRepos = async (domain: string): Promise => { const path = resolveServerPath("/api/repos"); const result = await fetch(path, { method: "GET", headers: { "Content-Type": "application/json", + "X-Org-Domain": domain, }, }).then(response => response.json()); diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index b8989a6c..28a0a11e 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -1,16 +1,26 @@ 'use server'; import { listRepositories } from "@/lib/server/searchService"; -import { getCurrentUserOrg } from "../../../../auth"; +import { NextRequest } from "next/server"; +import { withAuth, withOrgMembership } from "@/actions"; import { isServiceError } from "@/lib/utils"; import { serviceErrorResponse } from "@/lib/serviceError"; -export const GET = async () => { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return serviceErrorResponse(orgId); - } +export const GET = async (request: NextRequest) => { + const domain = request.headers.get("X-Org-Domain")!; + const response = await getRepos(domain); - const response = await listRepositories(orgId); + if (isServiceError(response)) { + return serviceErrorResponse(response); + } return Response.json(response); -} \ No newline at end of file +} + + +const getRepos = (domain: string) => + withAuth((session) => + withOrgMembership(session, domain, async (orgId) => { + const response = await listRepositories(orgId); + return response; + }) + ); \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts index 0c402bde..7b583b22 100644 --- a/packages/web/src/app/api/(server)/search/route.ts +++ b/packages/web/src/app/api/(server)/search/route.ts @@ -2,18 +2,14 @@ import { search } from "@/lib/server/searchService"; import { searchRequestSchema } from "@/lib/schemas"; -import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; -import { getCurrentUserOrg } from "../../../../auth"; +import { withAuth, withOrgMembership } from "@/actions"; +import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { SearchRequest } from "@/lib/types"; export const POST = async (request: NextRequest) => { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return serviceErrorResponse(orgId); - } - - console.log(`Searching for org ${orgId}`); + const domain = request.headers.get("X-Org-Domain")!; const body = await request.json(); const parsed = await searchRequestSchema.safeParseAsync(body); if (!parsed.success) { @@ -21,12 +17,17 @@ export const POST = async (request: NextRequest) => { schemaValidationError(parsed.error) ); } - - - const response = await search(parsed.data, orgId); + + const response = await postSearch(parsed.data, domain); if (isServiceError(response)) { return serviceErrorResponse(response); } - return Response.json(response); -} \ No newline at end of file +} + +const postSearch = (request: SearchRequest, domain: string) => + withAuth((session) => + withOrgMembership(session, domain, async (orgId) => { + const response = await search(request, orgId); + return response; + })) \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index 89c5a8bb..9b47c21c 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -5,14 +5,10 @@ import { getFileSource } from "@/lib/server/searchService"; import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; -import { getCurrentUserOrg } from "@/auth"; +import { withAuth, withOrgMembership } from "@/actions"; +import { FileSourceRequest } from "@/lib/types"; export const POST = async (request: NextRequest) => { - const orgId = await getCurrentUserOrg(); - if (isServiceError(orgId)) { - return serviceErrorResponse(orgId); - } - const body = await request.json(); const parsed = await fileSourceRequestSchema.safeParseAsync(body); if (!parsed.success) { @@ -21,10 +17,19 @@ export const POST = async (request: NextRequest) => { ); } - const response = await getFileSource(parsed.data, orgId); + + const response = await postSource(parsed.data, request.headers.get("X-Org-Domain")!); if (isServiceError(response)) { return serviceErrorResponse(response); } return Response.json(response); } + + +const postSource = (request: FileSourceRequest, domain: string) => + withAuth(async (session) => + withOrgMembership(session, domain, async (orgId) => { + const response = await getFileSource(request, orgId); + return response; + })); diff --git a/packages/web/src/app/components/orgSelector/orgCreationDialog.tsx b/packages/web/src/app/components/orgSelector/orgCreationDialog.tsx deleted file mode 100644 index 082cfb84..00000000 --- a/packages/web/src/app/components/orgSelector/orgCreationDialog.tsx +++ /dev/null @@ -1,97 +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(3).max(30), - domain: z.string().min(2).max(20) -}); - - -interface OrgCreationDialogProps { - onSubmit: (data: z.infer) => void; - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; -} - -export const OrgCreationDialog = ({ - onSubmit, - isOpen, - onOpenChange, -}: OrgCreationDialogProps) => { - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: "", - }, - }); - - return ( - - - e.preventDefault()} - > - - - - - - Create an organization - Organizations allow you to collaborate with team members. - -
- - ( - - Organization name - - - - - - )} - /> - ( - - Organization Domain - -
- - .sourcebot.dev -
-
- -
- )} - /> - - - -
-
- ) -} diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index c9c82221..585aee64 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -6,86 +6,17 @@ import { PHProvider } from "./posthogProvider"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { SessionProvider } from "next-auth/react"; -import { getCurrentUserOrg } from "@/auth"; -import { isServiceError } from "@/lib/utils"; -import { NavigationMenu } from "./components/navigationMenu"; -import { NoOrganizationCard } from "./components/noOrganizationCard"; -import { PaywallCard } from "./components/payWall/paywallCard"; -import { Footer } from "./components/footer"; -import { headers } from "next/headers"; -import { fetchSubscription } from "@/actions"; export const metadata: Metadata = { title: "Sourcebot", description: "Sourcebot", }; -export default async function RootLayout({ +export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const orgId = await getCurrentUserOrg(); - console.log(`orgId: ${orgId}`); - - const byPassOrgCheck = (await headers()).get("x-bypass-org-check")! == "true"; - console.log(`bypassOrgCheck: ${byPassOrgCheck}`); - if (isServiceError(orgId) && !byPassOrgCheck) { - return ( - - -
- - - -
- -
- ) - - - ) - } - - const bypassPaywall = (await headers()).get("x-bypass-paywall")! == "true"; - console.log(bypassPaywall); - if (!isServiceError(orgId) && !bypassPaywall) { - const subscription = await fetchSubscription(orgId as number); - if (isServiceError(subscription)) { - // TODO: display something better here - return ( -
- Error: {subscription.message} -
- ) - } - console.log(subscription.status); - - if(subscription.status !== "active" && subscription.status !== "trialing") { - return ( - - -
- - - -
- -
- - - ) - } - } - return ( ); -} +} \ No newline at end of file diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx index 4e9f5e34..2c0b233b 100644 --- a/packages/web/src/app/not-found.tsx +++ b/packages/web/src/app/not-found.tsx @@ -1,4 +1,4 @@ -import { PageNotFound } from "./components/pageNotFound"; +import { PageNotFound } from "./[domain]/components/pageNotFound"; export default function NotFoundPage() { return ( diff --git a/packages/web/src/app/onboard/complete/page.tsx b/packages/web/src/app/onboard/complete/page.tsx index 1668b6cf..fc3c5bc5 100644 --- a/packages/web/src/app/onboard/complete/page.tsx +++ b/packages/web/src/app/onboard/complete/page.tsx @@ -47,11 +47,5 @@ export default async function OnboardComplete({ searchParams }: OnboardCompleteP return ; } - const orgSwitchRes = await switchActiveOrg(res.id); - if (isServiceError(orgSwitchRes)) { - console.error("Failed to switch active org"); - return ; - } - redirect("/"); } \ No newline at end of file diff --git a/packages/web/src/app/onboard/components/orgCreateForm.tsx b/packages/web/src/app/onboard/components/orgCreateForm.tsx index e443af96..94daf69c 100644 --- a/packages/web/src/app/onboard/components/orgCreateForm.tsx +++ b/packages/web/src/app/onboard/components/orgCreateForm.tsx @@ -41,6 +41,11 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) { async function submitOrgInfoForm(data: OnboardingFormValues) { const res = await checkIfOrgDomainExists(data.domain); + if (isServiceError(res)) { + setErrorMessage("An error occurred while checking the domain. Please try clearing your cookies and trying again."); + return; + } + if (res) { setErrorMessage("Organization domain already exists. Please try a different one."); return; diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index 3a0cc946..9935e2a1 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -1,12 +1,26 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect} from "react"; import { OrgCreateForm, OnboardingFormValues } from "./components/orgCreateForm"; import { TrialCard } from "./components/trialInfoCard"; +import { isAuthed } from "@/actions"; +import { useRouter } from "next/navigation"; export default function Onboarding() { + const router = useRouter(); const [orgCreateInfo, setOrgInfo] = useState(undefined); + useEffect(() => { + const redirectIfNotAuthed = async () => { + const authed = await isAuthed(); + if(!authed) { + router.push("/login"); + } + } + + redirectIfNotAuthed(); + }, []); + return (
{orgCreateInfo ? ( @@ -18,4 +32,4 @@ export default function Onboarding() { )}
); -} \ No newline at end of file +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 4d7a2f10..86b0aa9b 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,194 +1,35 @@ -import { listRepositories } from "@/lib/server/searchService"; -import { isServiceError } from "@/lib/utils"; -import Image from "next/image"; -import { Suspense } from "react"; -import logoDark from "@/public/sb_logo_dark_large.png"; -import logoLight from "@/public/sb_logo_light_large.png"; -import { NavigationMenu } from "./components/navigationMenu"; -import { RepositoryCarousel } from "./components/repositoryCarousel"; -import { SearchBar } from "./components/searchBar"; -import { Separator } from "@/components/ui/separator"; -import { SymbolIcon } from "@radix-ui/react-icons"; -import { UpgradeToast } from "./components/upgradeToast"; -import Link from "next/link"; -import { getCurrentUserOrg } from "../auth" -import { Footer } from "./components/footer"; +import { auth } from "@/auth"; +import { prisma } from "@/prisma"; +import { redirect } from "next/navigation"; - -export default async function Home() { - const orgId = await getCurrentUserOrg(); - - return ( -
- - - - {isServiceError(orgId) ? ( -
- You are not authenticated. Please log in to continue. -
- ) : ( -
-
- {"Sourcebot - {"Sourcebot -
- -
- ...
}> - - -
-
- - How to search -
- - - test todo (both test and todo) - - - test or todo (either test or todo) - - - {`"exit boot"`} (exact match) - - - TODO case:yes (case sensitive) - - - - - file:README setup (by filename) - - - repo:torvalds/linux test (by repo) - - - lang:typescript (by language) - - - rev:HEAD (by branch or tag) - - - - - file:{`\\.py$`} {`(files that end in ".py")`} - - - sym:main {`(symbols named "main")`} - - - todo -lang:c (negate filter) - - - content:README (search content only) - - -
-
-
- )} - -
-
- ) -} - -const RepositoryList = async ({ orgId }: { orgId: number}) => { - const _repos = await listRepositories(orgId); - - if (isServiceError(_repos)) { - return null; +export default async function Page() { + const session = await auth(); + if (!session) { + return redirect("/login"); } - const repos = _repos.List.Repos.map((repo) => repo.Repository); + const firstOrg = await prisma.userToOrg.findFirst({ + where: { + userId: session.user.id, + org: { + members: { + some: { + userId: session.user.id, + } + } + } + }, + include: { + org: true + }, + orderBy: { + joinedAt: "asc" + } + }); - if (repos.length === 0) { - return ( -
- - indexing in progress... -
- ) + if (!firstOrg) { + return redirect("/onboard"); } - return ( -
- - {`Search ${repos.length} `} - - {repos.length > 1 ? 'repositories' : 'repository'} - - - -
- ) -} - -const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => { - return ( -
- {title} - {children} -
- ) - -} - -const Highlight = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ) -} - -const QueryExample = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ) -} - -const QueryExplanation = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ) -} - -const Query = ({ query, children }: { query: string, children: React.ReactNode }) => { - return ( - - {children} - - ) -} + return redirect(`/${firstOrg.org.domain}`); +} \ No newline at end of file diff --git a/packages/web/src/app/redeem/components/acceptInviteButton.tsx b/packages/web/src/app/redeem/components/acceptInviteButton.tsx index f521e62e..c20827c4 100644 --- a/packages/web/src/app/redeem/components/acceptInviteButton.tsx +++ b/packages/web/src/app/redeem/components/acceptInviteButton.tsx @@ -9,45 +9,45 @@ import { Button } from "@/components/ui/button" import { Invite } from "@sourcebot/db" interface AcceptInviteButtonProps { - invite: Invite - userId: string + invite: Invite + userId: string } export function AcceptInviteButton({ invite, userId }: AcceptInviteButtonProps) { - const [isLoading, setIsLoading] = useState(false) - const router = useRouter() - const { toast } = useToast() + const [isLoading, setIsLoading] = useState(false) + const router = useRouter() + const { toast } = useToast() - const handleAcceptInvite = async () => { - setIsLoading(true) - try { - const res = await redeemInvite(invite, userId) - if (isServiceError(res)) { - console.log("Failed to redeem invite: ", res) - toast({ - title: "Error", - description: "Failed to redeem invite. Please try again.", - variant: "destructive", - }) - } else { - router.push("/") - } - } catch (error) { - console.error("Error redeeming invite:", error) - toast({ - title: "Error", - description: "An unexpected error occurred. Please try again.", - variant: "destructive", - }) - } finally { - setIsLoading(false) + const handleAcceptInvite = async () => { + setIsLoading(true) + try { + const res = await redeemInvite(invite, userId) + if (isServiceError(res)) { + console.log("Failed to redeem invite: ", res) + toast({ + title: "Error", + description: "Failed to redeem invite. Please try again.", + variant: "destructive", + }) + } else { + router.push("/") + } + } catch (error) { + console.error("Error redeeming invite:", error) + toast({ + title: "Error", + description: "An unexpected error occurred. Please try again.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } } - } - return ( - - ) + return ( + + ) } diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx index 3ff9c3e2..e3eb2c97 100644 --- a/packages/web/src/app/redeem/page.tsx +++ b/packages/web/src/app/redeem/page.tsx @@ -1,6 +1,5 @@ import { prisma } from "@/prisma"; import { notFound, redirect } from 'next/navigation'; -import { NavigationMenu } from "../components/navigationMenu"; import { auth } from "@/auth"; import { getUser } from "@/data/user"; import { AcceptInviteButton } from "./components/acceptInviteButton" diff --git a/packages/web/src/app/settings/layout.tsx b/packages/web/src/app/settings/layout.tsx deleted file mode 100644 index 13d5109d..00000000 --- a/packages/web/src/app/settings/layout.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Metadata } from "next" -import Image from "next/image" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "./components/sidebar-nav" -import { NavigationMenu } from "../components/navigationMenu" - -export const metadata: Metadata = { - title: "Settings", -} - -const sidebarNavItems = [ - { - title: "Members", - href: "/settings", - }, - { - title: "Billing", - href: "/settings/billing", - } -] - -interface SettingsLayoutProps { - children: React.ReactNode -} - -export default function SettingsLayout({ children }: SettingsLayoutProps) { - return ( - <> -
- Forms - Forms -
- -
-
-

Settings

-

- Manage your organization settings. -

-
- -
- -
{children}
-
-
- - ) -} \ No newline at end of file diff --git a/packages/web/src/app/themeProvider.tsx b/packages/web/src/app/themeProvider.tsx deleted file mode 100644 index 6bd9e816..00000000 --- a/packages/web/src/app/themeProvider.tsx +++ /dev/null @@ -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 ( - - {children} - - ) -} \ No newline at end of file diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 003c4fe6..859342a9 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -1,15 +1,15 @@ import 'next-auth/jwt'; -import NextAuth, { User as AuthJsUser, DefaultSession } from "next-auth" +import NextAuth, { DefaultSession } from "next-auth" import GitHub from "next-auth/providers/github" import Google from "next-auth/providers/google" import { PrismaAdapter } from "@auth/prisma-adapter" import { prisma } from "@/prisma"; -import type { Provider } from "next-auth/providers" -import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET, AUTH_SECRET } from "./lib/environment"; +import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET, AUTH_SECRET, AUTH_URL } from "./lib/environment"; import { User } from '@sourcebot/db'; import { notAuthenticated, notFound, unexpectedError } from "@/lib/serviceError"; import { getUser } from "./data/user"; import { LuToggleRight } from 'react-icons/lu'; +import type { Provider } from "next-auth/providers"; declare module 'next-auth' { interface Session { @@ -21,9 +21,9 @@ declare module 'next-auth' { declare module 'next-auth/jwt' { interface JWT { - userId: string + userId: string } - } +} const providers: Provider[] = [ GitHub({ @@ -49,6 +49,9 @@ export const providerMap = providers .filter((provider) => provider.id !== "credentials"); +const useSecureCookies = AUTH_URL.startsWith("https://"); +const hostName = new URL(AUTH_URL).hostname; + export const { handlers, signIn, signOut, auth } = NextAuth({ secret: AUTH_SECRET, adapter: PrismaAdapter(prisma), @@ -74,6 +77,37 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ id: token.userId, } return session; + }, + }, + cookies: { + sessionToken: { + name: `${useSecureCookies ? '__Secure-' : ''}authjs.session-token`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + domain: `.${hostName}` + } + }, + callbackUrl: { + name: `${useSecureCookies ? '__Secure-' : ''}authjs.callback-url`, + options: { + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + domain: `.${hostName}` + } + }, + csrfToken: { + name: `${useSecureCookies ? '__Host-' : ''}authjs.csrf-token`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + domain: `.${hostName}` + } } }, providers: providers, diff --git a/packages/web/src/data/org.ts b/packages/web/src/data/org.ts new file mode 100644 index 00000000..dd1a4643 --- /dev/null +++ b/packages/web/src/data/org.ts @@ -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; +} \ No newline at end of file diff --git a/packages/web/src/hooks/useDomain.ts b/packages/web/src/hooks/useDomain.ts new file mode 100644 index 00000000..c1f953da --- /dev/null +++ b/packages/web/src/hooks/useDomain.ts @@ -0,0 +1,8 @@ +'use client'; + +import { useParams } from "next/navigation"; + +export const useDomain = () => { + const { domain } = useParams<{ domain: string }>(); + return domain; +} \ No newline at end of file diff --git a/packages/web/src/lib/environment.ts b/packages/web/src/lib/environment.ts index 01bb9ca0..72eb7559 100644 --- a/packages/web/src/lib/environment.ts +++ b/packages/web/src/lib/environment.ts @@ -12,5 +12,6 @@ export const AUTH_GITHUB_CLIENT_ID = getEnv(process.env.AUTH_GITHUB_CLIENT_ID); export const AUTH_GITHUB_CLIENT_SECRET = getEnv(process.env.AUTH_GITHUB_CLIENT_SECRET); export const AUTH_GOOGLE_CLIENT_ID = getEnv(process.env.AUTH_GOOGLE_CLIENT_ID); export const AUTH_GOOGLE_CLIENT_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET); +export const AUTH_URL = getEnv(process.env.AUTH_URL)!; -export const STRIPE_SECRET_KEY = getEnv(process.env.STRIPE_SECRET_KEY); \ No newline at end of file +export const STRIPE_SECRET_KEY = getEnv(process.env.STRIPE_SECRET_KEY); diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 27ecbe52..5276458d 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -1,9 +1,9 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" -import githubLogo from "../../public/github.svg"; -import gitlabLogo from "../../public/gitlab.svg"; -import giteaLogo from "../../public/gitea.svg"; -import gerritLogo from "../../public/gerrit.svg"; +import githubLogo from "@/public/github.svg"; +import gitlabLogo from "@/public/gitlab.svg"; +import giteaLogo from "@/public/gitea.svg"; +import gerritLogo from "@/public/gerrit.svg"; import { ServiceError } from "./serviceError"; import { Repository } from "./types"; diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index 88f7bd74..2923e1de 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -1,73 +1,50 @@ +import { NextResponse } from "next/server"; +import { auth } from "./auth" -import { auth } from "@/auth"; -import { Session } from "next-auth"; -import { NextRequest, NextResponse } from "next/server"; -import { notAuthenticated, serviceErrorResponse } from "./lib/serviceError"; +/* +// We're not able to check if the user doesn't belong to any orgs in the middleware, since we cannot call prisma. As a result, we do this check +// in the root layout. However, there are certain endpoints (ex. login, redeem, onboard) that we want the user to be able to hit even if they don't +// belong to an org. It seems like the easiest way to do this is to check for these paths here and pass in a flag to the root layout using the headers +// https://github.com/vercel/next.js/discussions/43657#discussioncomment-5981981 +const bypassOrgCheck = req.nextUrl.pathname === "/login" || req.nextUrl.pathname === "/redeem" || req.nextUrl.pathname.includes("onboard"); +const bypassPaywall = req.nextUrl.pathname === "/login" || req.nextUrl.pathname === "/redeem" || req.nextUrl.pathname.includes("onboard") || req.nextUrl.pathname.includes("settings"); +const requestheaders = new Headers(req.headers); +requestheaders.set("x-bypass-org-check", bypassOrgCheck.toString()); +requestheaders.set("x-bypass-paywall", bypassPaywall.toString()); +*/ -interface NextAuthRequest extends NextRequest { - auth: Session | null; - } +export default auth((request) => { + const host = request.headers.get("host")!; -const apiMiddleware = (req: NextAuthRequest) => { - if (req.nextUrl.pathname.startsWith("/api/auth")) { + const searchParams = request.nextUrl.searchParams.toString(); + const path = `${request.nextUrl.pathname}${ + searchParams.length > 0 ? `?${searchParams}` : "" + }`; + + if ( + host === process.env.NEXT_PUBLIC_ROOT_DOMAIN || + host === 'localhost:3000' + ) { + if (request.nextUrl.pathname === "/login" && request.auth) { + return NextResponse.redirect(new URL("/", request.url)); + } return NextResponse.next(); } - if (!req.auth) { - return serviceErrorResponse( - notAuthenticated(), - ); - } - - return NextResponse.next(); -} - -const defaultMiddleware = (req: NextAuthRequest) => { - // We're not able to check if the user doesn't belong to any orgs in the middleware, since we cannot call prisma. As a result, we do this check - // in the root layout. However, there are certain endpoints (ex. login, redeem, onboard) that we want the user to be able to hit even if they don't - // belong to an org. It seems like the easiest way to do this is to check for these paths here and pass in a flag to the root layout using the headers - // https://github.com/vercel/next.js/discussions/43657#discussioncomment-5981981 - const bypassOrgCheck = req.nextUrl.pathname === "/login" || req.nextUrl.pathname === "/redeem" || req.nextUrl.pathname.includes("onboard"); - const bypassPaywall = req.nextUrl.pathname === "/login" || req.nextUrl.pathname === "/redeem" || req.nextUrl.pathname.includes("onboard") || req.nextUrl.pathname.includes("settings"); - const requestheaders = new Headers(req.headers); - requestheaders.set("x-bypass-org-check", bypassOrgCheck.toString()); - requestheaders.set("x-bypass-paywall", bypassPaywall.toString()); - - // 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({ - request: { - headers: requestheaders, - } - }); - } - - 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({ - request: { - headers: requestheaders, - } - }); -} - -export default auth(async (req) => { - if (req.nextUrl.pathname.startsWith("/api")) { - return apiMiddleware(req); - } - - return defaultMiddleware(req); -}) + const subdomain = host.split(".")[0]; + return NextResponse.rewrite(new URL(`/${subdomain}${path}`, request.url)); +}); export const config = { // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher - matcher: ['/((?!_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'], + matcher: [ + /** + * Match all paths except for: + * 1. /api routes + * 2. _next/ routes + * 3. ingest (PostHog route) + */ + '/((?!api|_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt).*)' + ], } \ No newline at end of file diff --git a/schemas/v2/index.json b/schemas/v2/index.json index 11a58acb..ee70c0c2 100644 --- a/schemas/v2/index.json +++ b/schemas/v2/index.json @@ -141,10 +141,6 @@ ["docs", "core"] ] }, - "tenantId": { - "type": "number", - "description": "@nocheckin" - }, "exclude": { "type": "object", "properties": { diff --git a/supervisord.conf b/supervisord.conf index 7059ec61..ea746564 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -23,7 +23,7 @@ redirect_stderr=true [program:backend] -command=./prefix-output.sh node packages/backend/dist/index.js --configPath %(ENV_CONFIG_PATH)s --cacheDir %(ENV_DATA_CACHE_DIR)s +command=./prefix-output.sh node packages/backend/dist/index.js --cacheDir %(ENV_DATA_CACHE_DIR)s autostart=true autorestart=true startretries=3 diff --git a/yarn.lock b/yarn.lock index 411ea955..a901b157 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2448,6 +2448,11 @@ resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz" integrity sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA== +"@types/psl@^1.1.3": + version "1.1.3" + resolved "https://registry.npmjs.org/@types/psl/-/psl-1.1.3.tgz" + integrity sha512-Iu174JHfLd7i/XkXY6VDrqSlPvTDQOtQI7wNAXKKOAADJ9TduRLkNdMgjGiMxSttUIZnomv81JAbAbC0DhggxA== + "@types/react-dom@^18": version "18.3.0" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz" @@ -6215,6 +6220,13 @@ ps-tree@^1.2.0: dependencies: event-stream "=3.3.4" +psl@^1.15.0: + version "1.15.0" + resolved "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz" + integrity sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w== + dependencies: + punycode "^2.3.1" + punycode.js@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz"