mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
Domain support (#188)
This commit is contained in:
parent
568ded8dd2
commit
34c9c1d9a8
101 changed files with 2103 additions and 1976 deletions
11
Dockerfile
11
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
|
ARG NEXT_PUBLIC_SOURCEBOT_VERSION=BAKED_NEXT_PUBLIC_SOURCEBOT_VERSION
|
||||||
ENV NEXT_PUBLIC_POSTHOG_PAPIK=BAKED_NEXT_PUBLIC_POSTHOG_PAPIK
|
ENV NEXT_PUBLIC_POSTHOG_PAPIK=BAKED_NEXT_PUBLIC_POSTHOG_PAPIK
|
||||||
|
|
||||||
# We declare SOURCEBOT_ENCRYPTION_KEY here since it's read during the build stage, since it's read in a server side component
|
|
||||||
ARG SOURCEBOT_ENCRYPTION_KEY
|
|
||||||
ENV SOURCEBOT_ENCRYPTION_KEY=$SOURCEBOT_ENCRYPTION_KEY
|
|
||||||
|
|
||||||
# @nocheckin: This was interfering with the the `matcher` regex in middleware.ts,
|
# @nocheckin: This was interfering with the the `matcher` regex in middleware.ts,
|
||||||
# causing regular expressions parsing errors when making a request. It's unclear
|
# causing regular expressions parsing errors when making a request. It's unclear
|
||||||
# why exactly this was happening, but it's likely due to a bad replacement happening
|
# why exactly this was happening, but it's likely due to a bad replacement happening
|
||||||
|
|
@ -79,21 +75,16 @@ WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
ENV CONFIG_PATH=$DATA_DIR/config.json
|
|
||||||
ENV DATA_CACHE_DIR=$DATA_DIR/.sourcebot
|
ENV DATA_CACHE_DIR=$DATA_DIR/.sourcebot
|
||||||
ENV DB_DATA_DIR=$DATA_CACHE_DIR/db
|
ENV DB_DATA_DIR=$DATA_CACHE_DIR/db
|
||||||
ENV DB_NAME=sourcebot
|
ENV DB_NAME=sourcebot
|
||||||
ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot"
|
ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot"
|
||||||
|
ENV SRC_TENANT_ENFORCEMENT_MODE=strict
|
||||||
|
|
||||||
ARG SOURCEBOT_VERSION=unknown
|
ARG SOURCEBOT_VERSION=unknown
|
||||||
ENV SOURCEBOT_VERSION=$SOURCEBOT_VERSION
|
ENV SOURCEBOT_VERSION=$SOURCEBOT_VERSION
|
||||||
RUN echo "Sourcebot Version: $SOURCEBOT_VERSION"
|
RUN echo "Sourcebot Version: $SOURCEBOT_VERSION"
|
||||||
|
|
||||||
# Redeclare SOURCEBOT_ENCRYPTION_KEY so that we have it in the runner
|
|
||||||
ARG SOURCEBOT_ENCRYPTION_KEY
|
|
||||||
|
|
||||||
ENV SOURCEBOT_TENANT_MODE=single
|
|
||||||
|
|
||||||
# Valid values are: debug, info, warn, error
|
# Valid values are: debug, info, warn, error
|
||||||
ENV SOURCEBOT_LOG_LEVEL=info
|
ENV SOURCEBOT_LOG_LEVEL=info
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,27 +86,6 @@ fi
|
||||||
|
|
||||||
echo "{\"version\": \"$SOURCEBOT_VERSION\", \"install_id\": \"$SOURCEBOT_INSTALL_ID\"}" > "$FIRST_RUN_FILE"
|
echo "{\"version\": \"$SOURCEBOT_VERSION\", \"install_id\": \"$SOURCEBOT_INSTALL_ID\"}" > "$FIRST_RUN_FILE"
|
||||||
|
|
||||||
if [ ! -z "$SOURCEBOT_TENANT_MODE" ]; then
|
|
||||||
echo -e "\e[34m[Info] Sourcebot tenant mode: $SOURCEBOT_TENANT_MODE\e[0m"
|
|
||||||
else
|
|
||||||
echo -e "\e[31m[Error] SOURCEBOT_TENANT_MODE is not set.\e[0m"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If we're in single tenant mode, fallback to sample config if a config does not exist
|
|
||||||
if [ "$SOURCEBOT_TENANT_MODE" = "single" ]; then
|
|
||||||
if echo "$CONFIG_PATH" | grep -qE '^https?://'; then
|
|
||||||
if ! curl --output /dev/null --silent --head --fail "$CONFIG_PATH"; then
|
|
||||||
echo -e "\e[33m[Warning] Remote config file at '$CONFIG_PATH' not found. Falling back on sample config.\e[0m"
|
|
||||||
CONFIG_PATH="./default-config.json"
|
|
||||||
fi
|
|
||||||
elif [ ! -f "$CONFIG_PATH" ]; then
|
|
||||||
echo -e "\e[33m[Warning] Config file at '$CONFIG_PATH' not found. Falling back on sample config.\e[0m"
|
|
||||||
CONFIG_PATH="./default-config.json"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "\e[34m[Info] Using config file at: '$CONFIG_PATH'.\e[0m"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update NextJs public env variables w/o requiring a rebuild.
|
# Update NextJs public env variables w/o requiring a rebuild.
|
||||||
# @see: https://phase.dev/blog/nextjs-public-runtime-variables/
|
# @see: https://phase.dev/blog/nextjs-public-runtime-variables/
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn workspaces run build",
|
"build": "yarn workspaces run build",
|
||||||
"test": "yarn workspaces run test",
|
"test": "yarn workspaces run test",
|
||||||
"dev": "cross-env SOURCEBOT_TENANT_MODE=single npm-run-all --print-label dev:start",
|
"dev": "yarn workspace @sourcebot/db prisma:migrate:dev && cross-env npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web",
|
||||||
"dev:mt": "cross-env SOURCEBOT_TENANT_MODE=multi npm-run-all --print-label dev:start:mt",
|
"dev:zoekt": "export PATH=\"$PWD/bin:$PATH\" && export SRC_TENANT_ENFORCEMENT_MODE=strict && zoekt-webserver -index .sourcebot/index -rpc",
|
||||||
"dev:start": "yarn workspace @sourcebot/db prisma:migrate:dev && cross-env npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web",
|
|
||||||
"dev:start:mt": "yarn workspace @sourcebot/db prisma:migrate:dev && cross-env npm-run-all --print-label --parallel dev:zoekt:mt dev:backend dev:web",
|
|
||||||
"dev:zoekt": "export PATH=\"$PWD/bin:$PATH\" && export SRC_TENANT_ENFORCEMENT_MODE=none && zoekt-webserver -index .sourcebot/index -rpc",
|
|
||||||
"dev:zoekt:mt": "export PATH=\"$PWD/bin:$PATH\" && export SRC_TENANT_ENFORCEMENT_MODE=strict && zoekt-webserver -index .sourcebot/index -rpc",
|
|
||||||
"dev:backend": "yarn workspace @sourcebot/backend dev:watch",
|
"dev:backend": "yarn workspace @sourcebot/backend dev:watch",
|
||||||
"dev:web": "yarn workspace @sourcebot/web dev"
|
"dev:web": "yarn workspace @sourcebot/web dev"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:watch": "tsc-watch --preserveWatchOutput --onSuccess \"yarn dev --configPath ../../config.json --cacheDir ../../.sourcebot\"",
|
"dev:watch": "tsc-watch --preserveWatchOutput --onSuccess \"yarn dev --cacheDir ../../.sourcebot\"",
|
||||||
"dev": "export PATH=\"$PWD/../../bin:$PATH\" && export CTAGS_COMMAND=ctags && node ./dist/index.js",
|
"dev": "export PATH=\"$PWD/../../bin:$PATH\" && export CTAGS_COMMAND=ctags && node ./dist/index.js",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest --config ./vitest.config.ts"
|
"test": "vitest --config ./vitest.config.ts"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import dotenv from 'dotenv';
|
||||||
|
|
||||||
export const getEnv = (env: string | undefined, defaultValue?: string, required?: boolean) => {
|
export const getEnv = (env: string | undefined, defaultValue?: string, required?: boolean) => {
|
||||||
if (required && !env && !defaultValue) {
|
if (required && !env && !defaultValue) {
|
||||||
throw new Error(`Missing required environment variable`);
|
throw new Error(`Missing required environment variable: ${env}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return env ?? defaultValue;
|
return env ?? defaultValue;
|
||||||
|
|
@ -20,7 +20,6 @@ dotenv.config({
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const SOURCEBOT_TENANT_MODE = getEnv(process.env.SOURCEBOT_TENANT_MODE, undefined, true);
|
|
||||||
export const SOURCEBOT_LOG_LEVEL = getEnv(process.env.SOURCEBOT_LOG_LEVEL, 'info')!;
|
export const SOURCEBOT_LOG_LEVEL = getEnv(process.env.SOURCEBOT_LOG_LEVEL, 'info')!;
|
||||||
export const SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.env.SOURCEBOT_TELEMETRY_DISABLED, false)!;
|
export const SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.env.SOURCEBOT_TELEMETRY_DISABLED, false)!;
|
||||||
export const SOURCEBOT_INSTALL_ID = getEnv(process.env.SOURCEBOT_INSTALL_ID, 'unknown')!;
|
export const SOURCEBOT_INSTALL_ID = getEnv(process.env.SOURCEBOT_INSTALL_ID, 'unknown')!;
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@ import { ArgumentParser } from "argparse";
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { mkdir } from 'fs/promises';
|
import { mkdir } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { isRemotePath } from "./utils.js";
|
|
||||||
import { AppContext } from "./types.js";
|
import { AppContext } from "./types.js";
|
||||||
import { main } from "./main.js"
|
import { main } from "./main.js"
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { SOURCEBOT_TENANT_MODE } from "./environment.js";
|
|
||||||
|
|
||||||
|
|
||||||
const parser = new ArgumentParser({
|
const parser = new ArgumentParser({
|
||||||
|
|
@ -18,22 +16,12 @@ type Arguments = {
|
||||||
cacheDir: string;
|
cacheDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
parser.add_argument("--configPath", {
|
|
||||||
help: "Path to config file",
|
|
||||||
required: SOURCEBOT_TENANT_MODE === "single",
|
|
||||||
});
|
|
||||||
|
|
||||||
parser.add_argument("--cacheDir", {
|
parser.add_argument("--cacheDir", {
|
||||||
help: "Path to .sourcebot cache directory",
|
help: "Path to .sourcebot cache directory",
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const args = parser.parse_args() as Arguments;
|
const args = parser.parse_args() as Arguments;
|
||||||
|
|
||||||
if (SOURCEBOT_TENANT_MODE === "single" && !isRemotePath(args.configPath) && !existsSync(args.configPath)) {
|
|
||||||
console.error(`Config file ${args.configPath} does not exist, and is required in single tenant mode`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheDir = args.cacheDir;
|
const cacheDir = args.cacheDir;
|
||||||
const reposPath = path.join(cacheDir, 'repos');
|
const reposPath = path.join(cacheDir, 'repos');
|
||||||
const indexPath = path.join(cacheDir, 'index');
|
const indexPath = path.join(cacheDir, 'index');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[domain]` on the table `Org` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `domain` to the `Org` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Org" ADD COLUMN "domain" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Org_domain_key" ON "Org"("domain");
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -107,6 +107,7 @@ model Invite {
|
||||||
model Org {
|
model Org {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
|
domain String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
members UserToOrg[]
|
members UserToOrg[]
|
||||||
|
|
@ -161,7 +162,6 @@ model User {
|
||||||
image String?
|
image String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
orgs UserToOrg[]
|
orgs UserToOrg[]
|
||||||
activeOrgId Int?
|
|
||||||
|
|
||||||
/// List of pending invites that the user has created
|
/// List of pending invites that the user has created
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
|
|
|
||||||
|
|
@ -147,10 +147,6 @@ const schema = {
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"tenantId": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "@nocheckin"
|
|
||||||
},
|
|
||||||
"exclude": {
|
"exclude": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -95,10 +95,6 @@ export interface GitHubConfig {
|
||||||
* @minItems 1
|
* @minItems 1
|
||||||
*/
|
*/
|
||||||
topics?: string[];
|
topics?: string[];
|
||||||
/**
|
|
||||||
* @nocheckin
|
|
||||||
*/
|
|
||||||
tenantId?: number;
|
|
||||||
exclude?: {
|
exclude?: {
|
||||||
/**
|
/**
|
||||||
* Exclude forked repositories from syncing.
|
* Exclude forked repositories from syncing.
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"posthog-js": "^1.161.5",
|
"posthog-js": "^1.161.5",
|
||||||
"pretty-bytes": "^6.1.1",
|
"pretty-bytes": "^6.1.1",
|
||||||
|
"psl": "^1.15.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
|
|
@ -120,6 +121,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/psl": "^1.1.3",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import Ajv from "ajv";
|
import Ajv from "ajv";
|
||||||
import { auth, getCurrentUserOrg } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { notAuthenticated, notFound, ServiceError, unexpectedError } from "@/lib/serviceError";
|
import { notAuthenticated, notFound, ServiceError, unexpectedError } from "@/lib/serviceError";
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
@ -12,255 +12,318 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
||||||
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
import { encrypt } from "@sourcebot/crypto"
|
import { encrypt } from "@sourcebot/crypto"
|
||||||
import { getConnection } from "./data/connection";
|
import { getConnection } from "./data/connection";
|
||||||
import { Prisma, Invite } from "@sourcebot/db";
|
import { ConnectionSyncStatus, Invite, Prisma } from "@sourcebot/db";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createSecret = async (key: string, value: string): Promise<{ success: boolean } | ServiceError> => {
|
export const withAuth = async <T>(fn: (session: Session) => Promise<T>) => {
|
||||||
const orgId = await getCurrentUserOrg();
|
const session = await auth();
|
||||||
if (isServiceError(orgId)) {
|
if (!session) {
|
||||||
return orgId;
|
return notAuthenticated();
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const encrypted = encrypt(value);
|
|
||||||
await prisma.secret.create({
|
|
||||||
data: {
|
|
||||||
orgId,
|
|
||||||
key,
|
|
||||||
encryptedValue: encrypted.encryptedData,
|
|
||||||
iv: encrypted.iv,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return unexpectedError(`Failed to create secret`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
}
|
||||||
|
return fn(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSecrets = async (): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => {
|
export const withOrgMembership = async <T>(session: Session, domain: string, fn: (orgId: number) => Promise<T>) => {
|
||||||
const orgId = await getCurrentUserOrg();
|
const org = await prisma.org.findUnique({
|
||||||
if (isServiceError(orgId)) {
|
|
||||||
return orgId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const secrets = await prisma.secret.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
orgId,
|
domain,
|
||||||
},
|
},
|
||||||
select: {
|
|
||||||
key: true,
|
|
||||||
createdAt: true
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return secrets.map((secret) => ({
|
if (!org) {
|
||||||
key: secret.key,
|
return notFound();
|
||||||
createdAt: secret.createdAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteSecret = async (key: string): Promise<{ success: boolean } | ServiceError> => {
|
|
||||||
const orgId = await getCurrentUserOrg();
|
|
||||||
if (isServiceError(orgId)) {
|
|
||||||
return orgId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.secret.delete({
|
|
||||||
where: {
|
|
||||||
orgId_key: {
|
|
||||||
orgId,
|
|
||||||
key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const createOrg = async (name: string): Promise<{ id: number } | ServiceError> => {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session) {
|
|
||||||
return notAuthenticated();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the org
|
|
||||||
const org = await prisma.org.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
members: {
|
|
||||||
create: {
|
|
||||||
userId: session.user.id,
|
|
||||||
role: "OWNER",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: org.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const switchActiveOrg = async (orgId: number): Promise<{ id: number } | ServiceError> => {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session) {
|
|
||||||
return notAuthenticated();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check to see if the user is a member of the org
|
|
||||||
// @todo: refactor this into a shared function
|
|
||||||
const membership = await prisma.userToOrg.findUnique({
|
const membership = await prisma.userToOrg.findUnique({
|
||||||
where: {
|
where: {
|
||||||
orgId_userId: {
|
orgId_userId: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
orgId,
|
orgId: org.id,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the user's active org
|
return fn(org.id);
|
||||||
await prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: session.user.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
activeOrgId: orgId,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: orgId,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createConnection = async (name: string, type: string, connectionConfig: string): Promise<{ id: number } | ServiceError> => {
|
export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> =>
|
||||||
const orgId = await getCurrentUserOrg();
|
withAuth(async (session) => {
|
||||||
if (isServiceError(orgId)) {
|
const org = await prisma.org.create({
|
||||||
return orgId;
|
data: {
|
||||||
}
|
name,
|
||||||
|
domain,
|
||||||
|
members: {
|
||||||
|
create: {
|
||||||
|
role: "OWNER",
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: session.user.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const parsedConfig = parseConnectionConfig(type, connectionConfig);
|
|
||||||
if (isServiceError(parsedConfig)) {
|
|
||||||
return parsedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await prisma.connection.create({
|
|
||||||
data: {
|
|
||||||
orgId,
|
|
||||||
name,
|
|
||||||
config: parsedConfig as unknown as Prisma.InputJsonValue,
|
|
||||||
connectionType: type,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateConnectionDisplayName = async (connectionId: number, name: string): Promise<{ success: boolean } | ServiceError> => {
|
|
||||||
const orgId = await getCurrentUserOrg();
|
|
||||||
if (isServiceError(orgId)) {
|
|
||||||
return orgId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await getConnection(connectionId, orgId);
|
|
||||||
if (!connection) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.connection.update({
|
|
||||||
where: {
|
|
||||||
id: connectionId,
|
|
||||||
orgId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string): Promise<{ success: boolean } | ServiceError> => {
|
|
||||||
const orgId = await getCurrentUserOrg();
|
|
||||||
if (isServiceError(orgId)) {
|
|
||||||
return orgId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await getConnection(connectionId, orgId);
|
|
||||||
if (!connection) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedConfig = parseConnectionConfig(connection.connectionType, config);
|
|
||||||
if (isServiceError(parsedConfig)) {
|
|
||||||
return parsedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connection.syncStatus === "SYNC_NEEDED" ||
|
|
||||||
connection.syncStatus === "IN_SYNC_QUEUE" ||
|
|
||||||
connection.syncStatus === "SYNCING") {
|
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
id: org.id,
|
||||||
errorCode: ErrorCode.CONNECTION_SYNC_ALREADY_SCHEDULED,
|
|
||||||
message: "Connection is already syncing. Please wait for the sync to complete before updating the connection.",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.connection.update({
|
|
||||||
where: {
|
|
||||||
id: connectionId,
|
|
||||||
orgId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
config: parsedConfig as unknown as Prisma.InputJsonValue,
|
|
||||||
syncStatus: "SYNC_NEEDED",
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> =>
|
||||||
success: true,
|
withAuth((session) =>
|
||||||
}
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
}
|
const secrets = await prisma.secret.findMany({
|
||||||
|
where: {
|
||||||
|
orgId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
key: true,
|
||||||
|
createdAt: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const deleteConnection = async (connectionId: number): Promise<{ success: boolean } | ServiceError> => {
|
return secrets.map((secret) => ({
|
||||||
const orgId = await getCurrentUserOrg();
|
key: secret.key,
|
||||||
if (isServiceError(orgId)) {
|
createdAt: secret.createdAt,
|
||||||
return orgId;
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await getConnection(connectionId, orgId);
|
}));
|
||||||
if (!connection) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.connection.delete({
|
export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
where: {
|
withAuth((session) =>
|
||||||
id: connectionId,
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
orgId,
|
try {
|
||||||
|
const encrypted = encrypt(value);
|
||||||
|
await prisma.secret.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
key,
|
||||||
|
encryptedValue: encrypted.encryptedData,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return unexpectedError(`Failed to create secret`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
await prisma.secret.delete({
|
||||||
|
where: {
|
||||||
|
orgId_key: {
|
||||||
|
orgId,
|
||||||
|
key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
export const getConnections = async (domain: string): Promise<
|
||||||
|
{
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
syncStatus: ConnectionSyncStatus,
|
||||||
|
connectionType: string,
|
||||||
|
updatedAt: Date,
|
||||||
|
syncedAt?: Date
|
||||||
|
}[] | ServiceError
|
||||||
|
> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
const connections = await prisma.connection.findMany({
|
||||||
|
where: {
|
||||||
|
orgId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return connections.map((connection) => ({
|
||||||
|
id: connection.id,
|
||||||
|
name: connection.name,
|
||||||
|
syncStatus: connection.syncStatus,
|
||||||
|
connectionType: connection.connectionType,
|
||||||
|
updatedAt: connection.updatedAt,
|
||||||
|
syncedAt: connection.syncedAt ?? undefined,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
const parsedConfig = parseConnectionConfig(type, connectionConfig);
|
||||||
|
if (isServiceError(parsedConfig)) {
|
||||||
|
return parsedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await prisma.connection.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
config: parsedConfig as unknown as Prisma.InputJsonValue,
|
||||||
|
connectionType: type,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: connection.id,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
const connection = await getConnection(connectionId, orgId);
|
||||||
|
if (!connection) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.connection.update({
|
||||||
|
where: {
|
||||||
|
id: connectionId,
|
||||||
|
orgId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
const connection = await getConnection(connectionId, orgId);
|
||||||
|
if (!connection) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedConfig = parseConnectionConfig(connection.connectionType, config);
|
||||||
|
if (isServiceError(parsedConfig)) {
|
||||||
|
return parsedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.syncStatus === "SYNC_NEEDED" ||
|
||||||
|
connection.syncStatus === "IN_SYNC_QUEUE" ||
|
||||||
|
connection.syncStatus === "SYNCING") {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.CONNECTION_SYNC_ALREADY_SCHEDULED,
|
||||||
|
message: "Connection is already syncing. Please wait for the sync to complete before updating the connection.",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.connection.update({
|
||||||
|
where: {
|
||||||
|
id: connectionId,
|
||||||
|
orgId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
config: parsedConfig as unknown as Prisma.InputJsonValue,
|
||||||
|
syncStatus: "SYNC_NEEDED",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
const connection = await getConnection(connectionId, orgId);
|
||||||
|
if (!connection) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.connection.delete({
|
||||||
|
where: {
|
||||||
|
id: connectionId,
|
||||||
|
orgId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const createInvite = async (email: string, userId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
console.log("Creating invite for", email, userId, orgId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.invite.create({
|
||||||
|
data: {
|
||||||
|
recipientEmail: email,
|
||||||
|
hostUserId: userId,
|
||||||
|
orgId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create invite:", error);
|
||||||
|
return unexpectedError("Failed to create invite");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const redeemInvite = async (invite: Invite, userId: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
|
withAuth(async () => {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userToOrg.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
orgId: invite.orgId,
|
||||||
|
role: "MEMBER",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.invite.delete({
|
||||||
|
where: {
|
||||||
|
id: invite.id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to redeem invite:", error);
|
||||||
|
return unexpectedError("Failed to redeem invite");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseConnectionConfig = (connectionType: string, config: string) => {
|
const parseConnectionConfig = (connectionType: string, config: string) => {
|
||||||
let parsedConfig: ConnectionConfig;
|
let parsedConfig: ConnectionConfig;
|
||||||
try {
|
try {
|
||||||
|
|
@ -301,58 +364,3 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
|
||||||
|
|
||||||
return parsedConfig;
|
return parsedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createInvite = async (email: string, userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => {
|
|
||||||
console.log("Creating invite for", email, userId, orgId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.invite.create({
|
|
||||||
data: {
|
|
||||||
recipientEmail: email,
|
|
||||||
hostUserId: userId,
|
|
||||||
orgId,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create invite:", error);
|
|
||||||
return unexpectedError("Failed to create invite");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const redeemInvite = async (invite: Invite, userId: string): Promise<{ orgId: number } | ServiceError> => {
|
|
||||||
try {
|
|
||||||
await prisma.userToOrg.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
orgId: invite.orgId,
|
|
||||||
role: "MEMBER",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
activeOrgId: invite.orgId,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.invite.delete({
|
|
||||||
where: {
|
|
||||||
id: invite.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
orgId: invite.orgId,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to redeem invite:", error);
|
|
||||||
return unexpectedError("Failed to redeem invite");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
import { FileHeader } from "@/app/components/fireHeader";
|
import { FileHeader } from "@/app/[domain]/components/fireHeader";
|
||||||
import { TopBar } from "@/app/components/topBar";
|
import { TopBar } from "@/app/[domain]/components/topBar";
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { getFileSource, listRepositories } from '@/lib/server/searchService';
|
import { getFileSource, listRepositories } from '@/lib/server/searchService';
|
||||||
import { base64Decode, isServiceError } from "@/lib/utils";
|
import { base64Decode, isServiceError } from "@/lib/utils";
|
||||||
import { CodePreview } from "./codePreview";
|
import { CodePreview } from "./codePreview";
|
||||||
import { PageNotFound } from "@/app/components/pageNotFound";
|
import { PageNotFound } from "@/app/[domain]/components/pageNotFound";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
import { LuFileX2, LuBookX } from "react-icons/lu";
|
import { LuFileX2, LuBookX } from "react-icons/lu";
|
||||||
import { getCurrentUserOrg } from "@/auth";
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
|
||||||
interface BrowsePageProps {
|
interface BrowsePageProps {
|
||||||
params: {
|
params: {
|
||||||
path: string[];
|
path: string[];
|
||||||
|
domain: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,18 +46,14 @@ export default async function BrowsePage({
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const orgId = await getCurrentUserOrg();
|
const org = await getOrgFromDomain(params.domain);
|
||||||
if (isServiceError(orgId)) {
|
if (!org) {
|
||||||
return (
|
return <PageNotFound />
|
||||||
<>
|
|
||||||
Error: {orgId.message}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo (bkellam) : We should probably have a endpoint to fetch repository metadata
|
// @todo (bkellam) : We should probably have a endpoint to fetch repository metadata
|
||||||
// given it's name or id.
|
// given it's name or id.
|
||||||
const reposResponse = await listRepositories(orgId);
|
const reposResponse = await listRepositories(org.id);
|
||||||
if (isServiceError(reposResponse)) {
|
if (isServiceError(reposResponse)) {
|
||||||
// @todo : proper error handling
|
// @todo : proper error handling
|
||||||
return (
|
return (
|
||||||
|
|
@ -81,6 +78,7 @@ export default async function BrowsePage({
|
||||||
<div className='sticky top-0 left-0 right-0 z-10'>
|
<div className='sticky top-0 left-0 right-0 z-10'>
|
||||||
<TopBar
|
<TopBar
|
||||||
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
|
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
|
||||||
|
domain={params.domain}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
<Separator />
|
||||||
{repo && (
|
{repo && (
|
||||||
|
|
@ -108,7 +106,7 @@ export default async function BrowsePage({
|
||||||
path={path}
|
path={path}
|
||||||
repoName={repoName}
|
repoName={repoName}
|
||||||
revisionName={revisionName ?? 'HEAD'}
|
revisionName={revisionName ?? 'HEAD'}
|
||||||
orgId={orgId}
|
orgId={org.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -8,7 +8,7 @@ import { autoPlacement, computePosition, offset, shift, VirtualElement } from "@
|
||||||
import { Link2Icon } from "@radix-ui/react-icons";
|
import { Link2Icon } from "@radix-ui/react-icons";
|
||||||
import { EditorView, SelectionRange } from "@uiw/react-codemirror";
|
import { EditorView, SelectionRange } from "@uiw/react-codemirror";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { resolveServerPath } from "../api/(client)/client";
|
import { resolveServerPath } from "../../api/(client)/client";
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
view: EditorView;
|
view: EditorView;
|
||||||
|
|
@ -3,8 +3,8 @@ import { NavigationMenu as NavigationMenuBase, NavigationMenuItem, NavigationMen
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import logoDark from "../../../public/sb_logo_dark_small.png";
|
import logoDark from "@/public/sb_logo_dark_small.png";
|
||||||
import logoLight from "../../../public/sb_logo_light_small.png";
|
import logoLight from "@/public/sb_logo_light_small.png";
|
||||||
import { SettingsDropdown } from "./settingsDropdown";
|
import { SettingsDropdown } from "./settingsDropdown";
|
||||||
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
|
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
@ -13,14 +13,19 @@ import { OrgSelector } from "./orgSelector";
|
||||||
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
||||||
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
||||||
|
|
||||||
export const NavigationMenu = async () => {
|
interface NavigationMenuProps {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavigationMenu = async ({
|
||||||
|
domain,
|
||||||
|
}: NavigationMenuProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-screen h-fit">
|
<div className="flex flex-col w-screen h-fit">
|
||||||
<div className="flex flex-row justify-between items-center py-1.5 px-3">
|
<div className="flex flex-row justify-between items-center py-1.5 px-3">
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={`/${domain}`}
|
||||||
className="mr-3 cursor-pointer"
|
className="mr-3 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -37,41 +42,43 @@ export const NavigationMenu = async () => {
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<OrgSelector />
|
<OrgSelector
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
<Separator orientation="vertical" className="h-6 mx-2" />
|
<Separator orientation="vertical" className="h-6 mx-2" />
|
||||||
|
|
||||||
<NavigationMenuBase>
|
<NavigationMenuBase>
|
||||||
<NavigationMenuList>
|
<NavigationMenuList>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<Link href="/" legacyBehavior passHref>
|
<Link href={`/${domain}`} legacyBehavior passHref>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
Search
|
Search
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<Link href="/repos" legacyBehavior passHref>
|
<Link href={`/${domain}/repos`} legacyBehavior passHref>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
Repositories
|
Repositories
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<Link href="/secrets" legacyBehavior passHref>
|
<Link href={`/${domain}/secrets`} legacyBehavior passHref>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
Secrets
|
Secrets
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<Link href="/connections" legacyBehavior passHref>
|
<Link href={`/${domain}/connections`} legacyBehavior passHref>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
Connections
|
Connections
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<Link href="/settings" legacyBehavior passHref>
|
<Link href={`/${domain}/settings`} legacyBehavior passHref>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
Settings
|
Settings
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
|
|
@ -1,20 +1,27 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { getUser, getUserOrgs } from "../../../data/user";
|
import { getUserOrgs } from "../../../../data/user";
|
||||||
import { OrgSelectorDropdown } from "./orgSelectorDropdown";
|
import { OrgSelectorDropdown } from "./orgSelectorDropdown";
|
||||||
|
import { prisma } from "@/prisma";
|
||||||
|
|
||||||
export const OrgSelector = async () => {
|
interface OrgSelectorProps {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrgSelector = async ({
|
||||||
|
domain,
|
||||||
|
}: OrgSelectorProps) => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orgs = await getUserOrgs(session.user.id);
|
const orgs = await getUserOrgs(session.user.id);
|
||||||
const activeOrg = orgs.find((org) => org.id === user.activeOrgId);
|
const activeOrg = await prisma.org.findUnique({
|
||||||
|
where: {
|
||||||
|
domain,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!activeOrg) {
|
if (!activeOrg) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +31,7 @@ export const OrgSelector = async () => {
|
||||||
orgs={orgs.map((org) => ({
|
orgs={orgs.map((org) => ({
|
||||||
name: org.name,
|
name: org.name,
|
||||||
id: org.id,
|
id: org.id,
|
||||||
|
domain: org.domain,
|
||||||
}))}
|
}))}
|
||||||
activeOrgId={activeOrg.id}
|
activeOrgId={activeOrg.id}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { createOrg, switchActiveOrg } from "@/actions";
|
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { OrgCreationDialog } from "./orgCreationDialog";
|
|
||||||
import { OrgIcon } from "./orgIcon";
|
import { OrgIcon } from "./orgIcon";
|
||||||
|
|
||||||
|
|
||||||
interface OrgSelectorDropdownProps {
|
interface OrgSelectorDropdownProps {
|
||||||
orgs: {
|
orgs: {
|
||||||
name: string,
|
name: string,
|
||||||
|
domain: string,
|
||||||
id: number,
|
id: number,
|
||||||
}[],
|
}[],
|
||||||
activeOrgId: number,
|
activeOrgId: number,
|
||||||
|
|
@ -26,7 +24,6 @@ export const OrgSelectorDropdown = ({
|
||||||
}: OrgSelectorDropdownProps) => {
|
}: OrgSelectorDropdownProps) => {
|
||||||
const [searchFilter, setSearchFilter] = useState("");
|
const [searchFilter, setSearchFilter] = useState("");
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const [isCreateOrgDialogOpen, setIsCreateOrgDialogOpen] = useState(false);
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -39,61 +36,13 @@ export const OrgSelectorDropdown = ({
|
||||||
];
|
];
|
||||||
}, [_orgs, activeOrg, activeOrgId]);
|
}, [_orgs, activeOrg, activeOrgId]);
|
||||||
|
|
||||||
const onSwitchOrg = useCallback((orgId: number, orgName: string) => {
|
const onSwitchOrg = useCallback((domain: string, orgName: string) => {
|
||||||
switchActiveOrg(orgId)
|
router.push(`/${domain}`);
|
||||||
.then((response) => {
|
toast({
|
||||||
if (isServiceError(response)) {
|
description: `✅ Switched to ${orgName}`,
|
||||||
toast({
|
});
|
||||||
description: `❌ Failed to switch organization. Reason: ${response.message}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: `✅ Switched to ${orgName}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
// Necessary to refresh the server component.
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}, [router, toast]);
|
}, [router, toast]);
|
||||||
|
|
||||||
const onCreateOrg = useCallback((name: string) => {
|
|
||||||
createOrg(name)
|
|
||||||
.then((response) => {
|
|
||||||
if (isServiceError(response)) {
|
|
||||||
throw response;
|
|
||||||
}
|
|
||||||
|
|
||||||
return switchActiveOrg(response.id);
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (isServiceError(response)) {
|
|
||||||
throw response;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: `✅ Organization '${name}' created successfully.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
// Necessary to refresh the server component.
|
|
||||||
router.refresh();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (isServiceError(error)) {
|
|
||||||
toast({
|
|
||||||
description: `❌ Failed to create organization. Reason: ${error.message}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsCreateOrgDialogOpen(false);
|
|
||||||
});
|
|
||||||
}, [router, toast]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/*
|
/*
|
||||||
We need to set `modal=false` to fix a issue with having a dialog menu inside of
|
We need to set `modal=false` to fix a issue with having a dialog menu inside of
|
||||||
|
|
@ -144,7 +93,7 @@ export const OrgSelectorDropdown = ({
|
||||||
// Need to include org id to handle duplicates.
|
// Need to include org id to handle duplicates.
|
||||||
value={`${org.name}-${org.id}`}
|
value={`${org.name}-${org.id}`}
|
||||||
className="w-full justify-between py-3 font-medium cursor-pointer"
|
className="w-full justify-between py-3 font-medium cursor-pointer"
|
||||||
onSelect={() => onSwitchOrg(org.id, org.name)}
|
onSelect={() => onSwitchOrg(org.domain, org.name)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-1.5 items-center">
|
<div className="flex flex-row gap-1.5 items-center">
|
||||||
<OrgIcon />
|
<OrgIcon />
|
||||||
|
|
@ -159,16 +108,6 @@ export const OrgSelectorDropdown = ({
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
{searchFilter.length === 0 && (
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<OrgCreationDialog
|
|
||||||
isOpen={isCreateOrgDialogOpen}
|
|
||||||
onOpenChange={setIsCreateOrgDialogOpen}
|
|
||||||
onSubmit={({ name }) => onCreateOrg(name)}
|
|
||||||
/>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|
@ -42,6 +42,7 @@ import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -92,6 +93,7 @@ export const SearchBar = ({
|
||||||
autoFocus,
|
autoFocus,
|
||||||
}: SearchBarProps) => {
|
}: SearchBarProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const domain = useDomain();
|
||||||
const tailwind = useTailwind();
|
const tailwind = useTailwind();
|
||||||
const suggestionBoxRef = useRef<HTMLDivElement>(null);
|
const suggestionBoxRef = useRef<HTMLDivElement>(null);
|
||||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
|
|
@ -202,11 +204,11 @@ export const SearchBar = ({
|
||||||
setIsSuggestionsEnabled(false);
|
setIsSuggestionsEnabled(false);
|
||||||
setIsHistorySearchEnabled(false);
|
setIsHistorySearchEnabled(false);
|
||||||
|
|
||||||
const url = createPathWithQueryParams('/search',
|
const url = createPathWithQueryParams(`/${domain}/search`,
|
||||||
[SearchQueryParams.query, query],
|
[SearchQueryParams.query, query],
|
||||||
);
|
);
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}, [router]);
|
}, [domain, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "react-icons/vsc";
|
} from "react-icons/vsc";
|
||||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||||
import { getDisplayTime } from "@/lib/utils";
|
import { getDisplayTime } from "@/lib/utils";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -33,9 +34,10 @@ export const useSuggestionsData = ({
|
||||||
suggestionMode,
|
suggestionMode,
|
||||||
suggestionQuery,
|
suggestionQuery,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const domain = useDomain();
|
||||||
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
|
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
|
||||||
queryKey: ["repoSuggestions"],
|
queryKey: ["repoSuggestions"],
|
||||||
queryFn: getRepos,
|
queryFn: () => getRepos(domain),
|
||||||
select: (data): Suggestion[] => {
|
select: (data): Suggestion[] => {
|
||||||
return data.List.Repos
|
return data.List.Repos
|
||||||
.map(r => r.Repository)
|
.map(r => r.Repository)
|
||||||
|
|
@ -52,7 +54,7 @@ export const useSuggestionsData = ({
|
||||||
queryFn: () => search({
|
queryFn: () => search({
|
||||||
query: `file:${suggestionQuery}`,
|
query: `file:${suggestionQuery}`,
|
||||||
maxMatchDisplayCount: 15,
|
maxMatchDisplayCount: 15,
|
||||||
}),
|
}, domain),
|
||||||
select: (data): Suggestion[] => {
|
select: (data): Suggestion[] => {
|
||||||
return data.Result.Files?.map((file) => ({
|
return data.Result.Files?.map((file) => ({
|
||||||
value: file.FileName
|
value: file.FileName
|
||||||
|
|
@ -67,7 +69,7 @@ export const useSuggestionsData = ({
|
||||||
queryFn: () => search({
|
queryFn: () => search({
|
||||||
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
|
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
|
||||||
maxMatchDisplayCount: 15,
|
maxMatchDisplayCount: 15,
|
||||||
}),
|
}, domain),
|
||||||
select: (data): Suggestion[] => {
|
select: (data): Suggestion[] => {
|
||||||
const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []);
|
const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []);
|
||||||
if (!symbols) {
|
if (!symbols) {
|
||||||
|
|
@ -86,7 +86,9 @@ export const SettingsDropdown = ({
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
signOut();
|
signOut({
|
||||||
|
redirectTo: "/login",
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -7,16 +7,18 @@ import { SettingsDropdown } from "./settingsDropdown";
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
defaultSearchQuery?: string;
|
defaultSearchQuery?: string;
|
||||||
|
domain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopBar = ({
|
export const TopBar = ({
|
||||||
defaultSearchQuery
|
defaultSearchQuery,
|
||||||
|
domain,
|
||||||
}: TopBarProps) => {
|
}: TopBarProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4 bg-background">
|
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4 bg-background">
|
||||||
<div className="grow flex flex-row gap-4 items-center">
|
<div className="grow flex flex-row gap-4 items-center">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={`/${domain}`}
|
||||||
className="shrink-0 cursor-pointer"
|
className="shrink-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -19,6 +19,7 @@ import { updateConnectionConfigAndScheduleSync } from "@/actions";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
|
|
||||||
interface ConfigSettingProps {
|
interface ConfigSettingProps {
|
||||||
|
|
@ -61,6 +62,7 @@ function ConfigSettingInternal<T>({
|
||||||
}) {
|
}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const domain = useDomain();
|
||||||
const formSchema = useMemo(() => {
|
const formSchema = useMemo(() => {
|
||||||
return z.object({
|
return z.object({
|
||||||
config: createZodConnectionConfigValidator(schema),
|
config: createZodConnectionConfigValidator(schema),
|
||||||
|
|
@ -77,7 +79,7 @@ function ConfigSettingInternal<T>({
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
updateConnectionConfigAndScheduleSync(connectionId, data.config)
|
updateConnectionConfigAndScheduleSync(connectionId, data.config, domain)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -94,7 +96,7 @@ function ConfigSettingInternal<T>({
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
})
|
})
|
||||||
}, [connectionId, router, toast]);
|
}, [connectionId, domain, router, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
||||||
|
|
@ -18,6 +18,7 @@ import { Loader2 } from "lucide-react";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
interface DeleteConnectionSettingProps {
|
interface DeleteConnectionSettingProps {
|
||||||
connectionId: number;
|
connectionId: number;
|
||||||
|
|
@ -28,13 +29,14 @@ export const DeleteConnectionSetting = ({
|
||||||
}: DeleteConnectionSettingProps) => {
|
}: DeleteConnectionSettingProps) => {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const domain = useDomain();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
deleteConnection(connectionId)
|
deleteConnection(connectionId, domain)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -44,14 +46,14 @@ export const DeleteConnectionSetting = ({
|
||||||
toast({
|
toast({
|
||||||
description: `✅ Connection deleted successfully.`
|
description: `✅ Connection deleted successfully.`
|
||||||
});
|
});
|
||||||
router.replace("/connections");
|
router.replace(`/${domain}/connections`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, [connectionId]);
|
}, [connectionId, domain, router, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
||||||
|
|
@ -5,6 +5,7 @@ import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
@ -28,6 +29,7 @@ export const DisplayNameSetting = ({
|
||||||
}: DisplayNameSettingProps) => {
|
}: DisplayNameSettingProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const domain = useDomain();
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|
@ -38,7 +40,7 @@ export const DisplayNameSetting = ({
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
updateConnectionDisplayName(connectionId, data.name)
|
updateConnectionDisplayName(connectionId, data.name, domain)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -53,7 +55,7 @@ export const DisplayNameSetting = ({
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, [connectionId, router, toast]);
|
}, [connectionId, domain, router, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { NotFound } from "@/app/components/notFound";
|
import { NotFound } from "@/app/[domain]/components/notFound";
|
||||||
import { getCurrentUserOrg } from "@/auth";
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
|
|
@ -12,17 +11,19 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { TabSwitcher } from "@/components/ui/tab-switcher";
|
import { TabSwitcher } from "@/components/ui/tab-switcher";
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import { getConnection, getLinkedRepos } from "@/data/connection";
|
import { getConnection, getLinkedRepos } from "@/data/connection";
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { ConnectionIcon } from "../components/connectionIcon";
|
import { ConnectionIcon } from "../components/connectionIcon";
|
||||||
import { Header } from "../../components/header";
|
import { Header } from "../../components/header";
|
||||||
import { ConfigSetting } from "./components/configSetting";
|
import { ConfigSetting } from "./components/configSetting";
|
||||||
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting";
|
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting";
|
||||||
import { DisplayNameSetting } from "./components/displayNameSetting";
|
import { DisplayNameSetting } from "./components/displayNameSetting";
|
||||||
import { RepoListItem } from "./components/repoListItem";
|
import { RepoListItem } from "./components/repoListItem";
|
||||||
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
import { PageNotFound } from "../../components/pageNotFound";
|
||||||
|
|
||||||
interface ConnectionManagementPageProps {
|
interface ConnectionManagementPageProps {
|
||||||
params: {
|
params: {
|
||||||
id: string;
|
id: string;
|
||||||
|
domain: string;
|
||||||
},
|
},
|
||||||
searchParams: {
|
searchParams: {
|
||||||
tab?: string;
|
tab?: string;
|
||||||
|
|
@ -33,13 +34,9 @@ export default async function ConnectionManagementPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: ConnectionManagementPageProps) {
|
}: ConnectionManagementPageProps) {
|
||||||
const orgId = await getCurrentUserOrg();
|
const org = await getOrgFromDomain(params.domain);
|
||||||
if (isServiceError(orgId)) {
|
if (!org) {
|
||||||
return (
|
return <PageNotFound />
|
||||||
<>
|
|
||||||
Error: {orgId.message}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionId = Number(params.id);
|
const connectionId = Number(params.id);
|
||||||
|
|
@ -52,7 +49,7 @@ export default async function ConnectionManagementPage({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const connection = await getConnection(Number(params.id), orgId);
|
const connection = await getConnection(Number(params.id), org.id);
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return (
|
return (
|
||||||
<NotFound
|
<NotFound
|
||||||
|
|
@ -62,7 +59,7 @@ export default async function ConnectionManagementPage({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkedRepos = await getLinkedRepos(connectionId, orgId);
|
const linkedRepos = await getLinkedRepos(connectionId, org.id);
|
||||||
|
|
||||||
const currentTab = searchParams.tab || "overview";
|
const currentTab = searchParams.tab || "overview";
|
||||||
|
|
||||||
|
|
@ -78,7 +75,7 @@ export default async function ConnectionManagementPage({
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink href="/connections">Connections</BreadcrumbLink>
|
<BreadcrumbLink href={`/${params.domain}/connections`}>Connections</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator />
|
<BreadcrumbSeparator />
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
|
|
@ -53,7 +53,7 @@ export const ConnectionListItem = ({
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/connections/${id}`}>
|
<Link href={`connections/${id}`}>
|
||||||
<div
|
<div
|
||||||
className="flex flex-row justify-between items-center border p-4 rounded-lg cursor-pointer bg-background"
|
className="flex flex-row justify-between items-center border p-4 rounded-lg cursor-pointer bg-background"
|
||||||
>
|
>
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import { Connection } from "@sourcebot/db"
|
|
||||||
import { ConnectionListItem } from "./connectionListItem";
|
import { ConnectionListItem } from "./connectionListItem";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||||
|
import { ConnectionSyncStatus } from "@sourcebot/db";
|
||||||
|
|
||||||
|
|
||||||
interface ConnectionListProps {
|
interface ConnectionListProps {
|
||||||
connections: Connection[];
|
connections: {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
connectionType: string,
|
||||||
|
syncStatus: ConnectionSyncStatus,
|
||||||
|
updatedAt: Date,
|
||||||
|
syncedAt?: Date
|
||||||
|
}[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ const Card = ({
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className="flex flex-row justify-between items-center cursor-pointer p-2"
|
className="flex flex-row justify-between items-center cursor-pointer p-2"
|
||||||
href={`/connections/new/${type}`}
|
href={`connections/new/${type}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{Icon}
|
{Icon}
|
||||||
|
|
@ -2,13 +2,15 @@ import { NavigationMenu } from "../components/navigationMenu";
|
||||||
|
|
||||||
export default function Layout({
|
export default function Layout({
|
||||||
children,
|
children,
|
||||||
|
params: { domain },
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
params: { domain: string };
|
||||||
}>) {
|
}>) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<NavigationMenu />
|
<NavigationMenu domain={domain} />
|
||||||
<main className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative">
|
<main className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative">
|
||||||
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
|
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createConnection } from "@/actions";
|
import { createConnection } from "@/actions";
|
||||||
import { ConnectionIcon } from "@/app/connections/components/connectionIcon";
|
import { ConnectionIcon } from "@/app/[domain]/connections/components/connectionIcon";
|
||||||
import { createZodConnectionConfigValidator } from "@/app/connections/utils";
|
import { createZodConnectionConfigValidator } from "@/app/[domain]/connections/utils";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
|
@ -16,6 +16,7 @@ import { useCallback, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ConfigEditor, QuickActionFn } from "../../../components/configEditor";
|
import { ConfigEditor, QuickActionFn } from "../../../components/configEditor";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
interface ConnectionCreationForm<T> {
|
interface ConnectionCreationForm<T> {
|
||||||
type: 'github' | 'gitlab';
|
type: 'github' | 'gitlab';
|
||||||
|
|
@ -41,6 +42,7 @@ export default function ConnectionCreationForm<T>({
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
const formSchema = useMemo(() => {
|
const formSchema = useMemo(() => {
|
||||||
return z.object({
|
return z.object({
|
||||||
|
|
@ -55,7 +57,7 @@ export default function ConnectionCreationForm<T>({
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
||||||
createConnection(data.name, type, data.config)
|
createConnection(data.name, type, data.config, domain)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -65,11 +67,11 @@ export default function ConnectionCreationForm<T>({
|
||||||
toast({
|
toast({
|
||||||
description: `✅ Connection created successfully.`
|
description: `✅ Connection created successfully.`
|
||||||
});
|
});
|
||||||
router.push('/connections');
|
router.push(`/${domain}/connections`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [router, toast, type]);
|
}, [domain, router, toast, type]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6">
|
<div className="flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6">
|
||||||
|
|
@ -1,27 +1,16 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { getUser } from "@/data/user";
|
|
||||||
import { prisma } from "@/prisma";
|
|
||||||
import { ConnectionList } from "./components/connectionList";
|
import { ConnectionList } from "./components/connectionList";
|
||||||
import { Header } from "../components/header";
|
import { Header } from "../components/header";
|
||||||
import { NewConnectionCard } from "./components/newConnectionCard";
|
import { NewConnectionCard } from "./components/newConnectionCard";
|
||||||
|
import NotFoundPage from "@/app/not-found";
|
||||||
|
import { getConnections } from "@/actions";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
|
||||||
export default async function ConnectionsPage() {
|
export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) {
|
||||||
const session = await auth();
|
const connections = await getConnections(domain);
|
||||||
if (!session) {
|
if (isServiceError(connections)) {
|
||||||
return null;
|
return <NotFoundPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
|
||||||
if (!user || !user.activeOrgId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connections = await prisma.connection.findMany({
|
|
||||||
where: {
|
|
||||||
orgId: user.activeOrgId,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header>
|
<Header>
|
||||||
42
packages/web/src/app/[domain]/layout.tsx
Normal file
42
packages/web/src/app/[domain]/layout.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { prisma } from "@/prisma";
|
||||||
|
import { PageNotFound } from "./components/pageNotFound";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode,
|
||||||
|
params: { domain: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Layout({
|
||||||
|
children,
|
||||||
|
params: { domain },
|
||||||
|
}: LayoutProps) {
|
||||||
|
const org = await getOrgFromDomain(domain);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return <PageNotFound />
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return <PageNotFound />
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const membership = await prisma.userToOrg.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_userId: {
|
||||||
|
orgId: org.id,
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return <PageNotFound />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
200
packages/web/src/app/[domain]/page.tsx
Normal file
200
packages/web/src/app/[domain]/page.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { listRepositories } from "@/lib/server/searchService";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import logoDark from "@/public/sb_logo_dark_large.png";
|
||||||
|
import logoLight from "@/public/sb_logo_light_large.png";
|
||||||
|
import { NavigationMenu } from "./components/navigationMenu";
|
||||||
|
import { RepositoryCarousel } from "./components/repositoryCarousel";
|
||||||
|
import { SearchBar } from "./components/searchBar";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||||
|
import { UpgradeToast } from "./components/upgradeToast";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
import { PageNotFound } from "./components/pageNotFound";
|
||||||
|
|
||||||
|
|
||||||
|
export default async function Home({ params: { domain } }: { params: { domain: string } }) {
|
||||||
|
const org = await getOrgFromDomain(domain);
|
||||||
|
if (!org) {
|
||||||
|
return <PageNotFound />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
||||||
|
<NavigationMenu
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
|
<UpgradeToast />
|
||||||
|
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
||||||
|
<div className="max-h-44 w-auto">
|
||||||
|
<Image
|
||||||
|
src={logoDark}
|
||||||
|
className="h-18 md:h-40 w-auto hidden dark:block"
|
||||||
|
alt={"Sourcebot logo"}
|
||||||
|
priority={true}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src={logoLight}
|
||||||
|
className="h-18 md:h-40 w-auto block dark:hidden"
|
||||||
|
alt={"Sourcebot logo"}
|
||||||
|
priority={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SearchBar
|
||||||
|
autoFocus={true}
|
||||||
|
className="mt-4 w-full max-w-[800px]"
|
||||||
|
/>
|
||||||
|
<div className="mt-8">
|
||||||
|
<Suspense fallback={<div>...</div>}>
|
||||||
|
<RepositoryList
|
||||||
|
orgId={org.id}
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center w-fit gap-6">
|
||||||
|
<Separator className="mt-5" />
|
||||||
|
<span className="font-semibold">How to search</span>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<HowToSection
|
||||||
|
title="Search in files or paths"
|
||||||
|
>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="test todo">test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="test or todo">test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query={`"exit boot"`}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="TODO case:yes">TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
</HowToSection>
|
||||||
|
<HowToSection
|
||||||
|
title="Filter results"
|
||||||
|
>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="file:README setup"><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="repo:torvalds/linux test"><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="lang:typescript"><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="rev:HEAD"><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
</HowToSection>
|
||||||
|
<HowToSection
|
||||||
|
title="Advanced"
|
||||||
|
>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="file:\.py$"><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="sym:main"><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="todo -lang:c">todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="content:README"><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
</HowToSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer className="w-full mt-auto py-4 flex flex-row justify-center items-center gap-4">
|
||||||
|
<Link href="https://sourcebot.dev" className="text-gray-400 text-sm hover:underline">About</Link>
|
||||||
|
<Separator orientation="vertical" className="h-4" />
|
||||||
|
<Link href="https://github.com/sourcebot-dev/sourcebot/issues/new" className="text-gray-400 text-sm hover:underline">Support</Link>
|
||||||
|
<Separator orientation="vertical" className="h-4" />
|
||||||
|
<Link href="mailto:team@sourcebot.dev" className="text-gray-400 text-sm hover:underline">Contact Us</Link>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RepositoryList = async ({ orgId, domain }: { orgId: number, domain: string }) => {
|
||||||
|
const _repos = await listRepositories(orgId);
|
||||||
|
|
||||||
|
if (isServiceError(_repos)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repos = _repos.List.Repos.map((repo) => repo.Repository);
|
||||||
|
|
||||||
|
if (repos.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center gap-3">
|
||||||
|
<SymbolIcon className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm">indexing in progress...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{`Search ${repos.length} `}
|
||||||
|
<Link
|
||||||
|
href={`${domain}/repos`}
|
||||||
|
className="text-blue-500"
|
||||||
|
>
|
||||||
|
{repos.length > 1 ? 'repositories' : 'repository'}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
<RepositoryCarousel repos={repos} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const Highlight = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-highlight">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryExample = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 ml-3">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Query = ({ query, children }: { query: string, children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/search?query=${query}`}
|
||||||
|
className="cursor-pointer hover:underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { NavigationMenu } from "../components/navigationMenu";
|
import { NavigationMenu } from "../components/navigationMenu";
|
||||||
import { RepositoryTable } from "./repositoryTable";
|
import { RepositoryTable } from "./repositoryTable";
|
||||||
import { getCurrentUserOrg } from "@/auth";
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { PageNotFound } from "../components/pageNotFound";
|
||||||
|
|
||||||
export default async function ReposPage() {
|
export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) {
|
||||||
const orgId = await getCurrentUserOrg();
|
const org = await getOrgFromDomain(domain);
|
||||||
if (isServiceError(orgId)) {
|
if (!org) {
|
||||||
return (
|
return <PageNotFound />
|
||||||
<>
|
|
||||||
Error: {orgId.message}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col items-center">
|
<div className="h-screen flex flex-col items-center">
|
||||||
<NavigationMenu />
|
<NavigationMenu domain={domain} />
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<div className="max-w-[90%]">
|
<div className="max-w-[90%]">
|
||||||
<RepositoryTable orgId={ orgId }/>
|
<RepositoryTable orgId={ org.id }/>
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { EditorContextMenu } from "@/app/components/editorContextMenu";
|
import { EditorContextMenu } from "@/app/[domain]/components/editorContextMenu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
||||||
|
|
@ -5,6 +5,7 @@ import { base64Decode } from "@/lib/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CodePreview, CodePreviewFile } from "./codePreview";
|
import { CodePreview, CodePreviewFile } from "./codePreview";
|
||||||
import { SearchResultFile } from "@/lib/types";
|
import { SearchResultFile } from "@/lib/types";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
interface CodePreviewPanelProps {
|
interface CodePreviewPanelProps {
|
||||||
fileMatch?: SearchResultFile;
|
fileMatch?: SearchResultFile;
|
||||||
|
|
@ -21,6 +22,7 @@ export const CodePreviewPanel = ({
|
||||||
onSelectedMatchIndexChange,
|
onSelectedMatchIndexChange,
|
||||||
repoUrlTemplates,
|
repoUrlTemplates,
|
||||||
}: CodePreviewPanelProps) => {
|
}: CodePreviewPanelProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
const { data: file } = useQuery({
|
const { data: file } = useQuery({
|
||||||
queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository, fileMatch?.Branches],
|
queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository, fileMatch?.Branches],
|
||||||
|
|
@ -37,7 +39,7 @@ export const CodePreviewPanel = ({
|
||||||
fileName: fileMatch.FileName,
|
fileName: fileMatch.FileName,
|
||||||
repository: fileMatch.Repository,
|
repository: fileMatch.Repository,
|
||||||
branch,
|
branch,
|
||||||
})
|
}, domain)
|
||||||
.then(({ source }) => {
|
.then(({ source }) => {
|
||||||
const link = (() => {
|
const link = (() => {
|
||||||
const template = repoUrlTemplates[fileMatch.Repository];
|
const template = repoUrlTemplates[fileMatch.Repository];
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FileHeader } from "@/app/components/fireHeader";
|
import { FileHeader } from "@/app/[domain]/components/fireHeader";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Repository, SearchResultFile } from "@/lib/types";
|
import { Repository, SearchResultFile } from "@/lib/types";
|
||||||
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
||||||
|
|
@ -16,11 +16,12 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
import { getRepos, search } from "../api/(client)/client";
|
import { getRepos, search } from "../../api/(client)/client";
|
||||||
import { TopBar } from "../components/topBar";
|
import { TopBar } from "../components/topBar";
|
||||||
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
||||||
import { FilterPanel } from "./components/filterPanel";
|
import { FilterPanel } from "./components/filterPanel";
|
||||||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
|
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
|
||||||
|
|
||||||
|
|
@ -42,13 +43,14 @@ const SearchPageInternal = () => {
|
||||||
const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
|
const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
|
||||||
const { setSearchHistory } = useSearchHistory();
|
const { setSearchHistory } = useSearchHistory();
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
const { data: searchResponse, isLoading } = useQuery({
|
const { data: searchResponse, isLoading } = useQuery({
|
||||||
queryKey: ["search", searchQuery, maxMatchDisplayCount],
|
queryKey: ["search", searchQuery, maxMatchDisplayCount],
|
||||||
queryFn: () => search({
|
queryFn: () => search({
|
||||||
query: searchQuery,
|
query: searchQuery,
|
||||||
maxMatchDisplayCount,
|
maxMatchDisplayCount,
|
||||||
}),
|
}, domain),
|
||||||
enabled: searchQuery.length > 0,
|
enabled: searchQuery.length > 0,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
@ -75,7 +77,7 @@ const SearchPageInternal = () => {
|
||||||
// for easy lookup.
|
// for easy lookup.
|
||||||
const { data: repoMetadata } = useQuery({
|
const { data: repoMetadata } = useQuery({
|
||||||
queryKey: ["repos"],
|
queryKey: ["repos"],
|
||||||
queryFn: () => getRepos(),
|
queryFn: () => getRepos(domain),
|
||||||
select: (data): Record<string, Repository> =>
|
select: (data): Record<string, Repository> =>
|
||||||
data.List.Repos
|
data.List.Repos
|
||||||
.map(r => r.Repository)
|
.map(r => r.Repository)
|
||||||
|
|
@ -185,7 +187,10 @@ const SearchPageInternal = () => {
|
||||||
<div className="flex flex-col h-screen overflow-clip">
|
<div className="flex flex-col h-screen overflow-clip">
|
||||||
{/* TopBar */}
|
{/* TopBar */}
|
||||||
<div className="sticky top-0 left-0 right-0 z-10">
|
<div className="sticky top-0 left-0 right-0 z-10">
|
||||||
<TopBar defaultSearchQuery={searchQuery} />
|
<TopBar
|
||||||
|
defaultSearchQuery={searchQuery}
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
<Separator />
|
<Separator />
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<div className="bg-accent py-1 px-2 flex flex-row items-center gap-4">
|
<div className="bg-accent py-1 px-2 flex flex-row items-center gap-4">
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
import { NavigationMenu } from "../components/navigationMenu";
|
import { NavigationMenu } from "../components/navigationMenu";
|
||||||
import { SecretsTable } from "./secretsTable";
|
import { SecretsTable } from "./secretsTable";
|
||||||
import { getSecrets } from "../../actions"
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { getSecrets } from "@/actions";
|
||||||
|
|
||||||
export interface SecretsTableProps {
|
export default async function SecretsPage({ params: { domain } }: { params: { domain: string } }) {
|
||||||
initialSecrets: { createdAt: Date; key: string; }[];
|
const secrets = await getSecrets(domain);
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SecretsPage() {
|
|
||||||
const secrets = await getSecrets();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col items-center">
|
<div className="h-screen flex flex-col items-center">
|
||||||
<NavigationMenu />
|
<NavigationMenu domain={domain} />
|
||||||
{ !isServiceError(secrets) && (
|
{ !isServiceError(secrets) && (
|
||||||
<div className="max-w-[90%]">
|
<div className="max-w-[90%]">
|
||||||
<SecretsTable initialSecrets={secrets} />
|
<SecretsTable
|
||||||
|
initialSecrets={secrets}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { getSecrets, createSecret } from "../../actions"
|
import { getSecrets, createSecret } from "../../../actions"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -11,21 +12,26 @@ import { columns, SecretColumnInfo } from "./columns";
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { deleteSecret } from "../../actions"
|
import { deleteSecret } from "../../../actions"
|
||||||
import { SecretsTableProps } from "./page";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
key: z.string().min(2).max(40),
|
key: z.string().min(2).max(40),
|
||||||
value: z.string().min(2).max(40),
|
value: z.string().min(2).max(40),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface SecretsTableProps {
|
||||||
|
initialSecrets: { createdAt: Date; key: string; }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
||||||
const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets);
|
const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
const fetchSecretKeys = async () => {
|
const fetchSecretKeys = async () => {
|
||||||
const keys = await getSecrets();
|
const keys = await getSecrets(domain);
|
||||||
if ('keys' in keys) {
|
if ('keys' in keys) {
|
||||||
setSecrets(keys);
|
setSecrets(keys);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -46,7 +52,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateSecret = async (values: { key: string, value: string }) => {
|
const handleCreateSecret = async (values: { key: string, value: string }) => {
|
||||||
const res = await createSecret(values.key, values.value);
|
const res = await createSecret(values.key, values.value, domain);
|
||||||
if (isServiceError(res)) {
|
if (isServiceError(res)) {
|
||||||
toast({
|
toast({
|
||||||
description: `❌ Failed to create secret`
|
description: `❌ Failed to create secret`
|
||||||
|
|
@ -58,7 +64,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = await getSecrets();
|
const keys = await getSecrets(domain);
|
||||||
if (isServiceError(keys)) {
|
if (isServiceError(keys)) {
|
||||||
console.error("Failed to fetch secrets");
|
console.error("Failed to fetch secrets");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -71,7 +77,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (key: string) => {
|
const handleDelete = async (key: string) => {
|
||||||
const res = await deleteSecret(key);
|
const res = await deleteSecret(key, domain);
|
||||||
if (isServiceError(res)) {
|
if (isServiceError(res)) {
|
||||||
toast({
|
toast({
|
||||||
description: `❌ Failed to delete secret`
|
description: `❌ Failed to delete secret`
|
||||||
|
|
@ -83,7 +89,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = await getSecrets();
|
const keys = await getSecrets(domain);
|
||||||
if ('keys' in keys) {
|
if ('keys' in keys) {
|
||||||
setSecrets(keys);
|
setSecrets(keys);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ColumnDef } from "@tanstack/react-table"
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { resolveServerPath } from "../../api/(client)/client";
|
import { resolveServerPath } from "@/app/api/(client)/client";
|
||||||
import { createPathWithQueryParams } from "@/lib/utils";
|
import { createPathWithQueryParams } from "@/lib/utils";
|
||||||
|
|
||||||
export type InviteColumnInfo = {
|
export type InviteColumnInfo = {
|
||||||
|
|
@ -6,15 +6,17 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { createInvite } from "../../../actions"
|
import { createInvite } from "@/actions"
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().min(2).max(40),
|
email: z.string().min(2).max(40),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MemberInviteForm = ({ orgId, userId }: { orgId: number, userId: string }) => {
|
export const MemberInviteForm = ({ userId }: { userId: string }) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
|
|
@ -24,7 +26,7 @@ export const MemberInviteForm = ({ orgId, userId }: { orgId: number, userId: str
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateInvite = async (values: { email: string }) => {
|
const handleCreateInvite = async (values: { email: string }) => {
|
||||||
const res = await createInvite(values.email, userId, orgId);
|
const res = await createInvite(values.email, userId, domain);
|
||||||
if (isServiceError(res)) {
|
if (isServiceError(res)) {
|
||||||
toast({
|
toast({
|
||||||
description: `❌ Failed to create invite`
|
description: `❌ Failed to create invite`
|
||||||
|
|
@ -2,13 +2,14 @@ import { NavigationMenu } from "../components/navigationMenu";
|
||||||
|
|
||||||
export default function Layout({
|
export default function Layout({
|
||||||
children,
|
children,
|
||||||
|
params: { domain },
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
params: { domain: string };
|
||||||
}>) {
|
}>) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<NavigationMenu />
|
<NavigationMenu domain={domain} />
|
||||||
<main className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative">
|
<main className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative">
|
||||||
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
|
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -6,7 +6,15 @@ import { MemberTable } from "./components/memberTable";
|
||||||
import { MemberInviteForm } from "./components/memberInviteForm";
|
import { MemberInviteForm } from "./components/memberInviteForm";
|
||||||
import { InviteTable } from "./components/inviteTable";
|
import { InviteTable } from "./components/inviteTable";
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
interface SettingsPageProps {
|
||||||
|
params: {
|
||||||
|
domain: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SettingsPage({
|
||||||
|
params: { domain }
|
||||||
|
}: SettingsPageProps) {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|
@ -14,7 +22,17 @@ export default async function SettingsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user || !user.activeOrgId) {
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeOrg = await prisma.org.findUnique({
|
||||||
|
where: {
|
||||||
|
domain,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeOrg) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,14 +40,14 @@ export default async function SettingsPage() {
|
||||||
where: {
|
where: {
|
||||||
orgs: {
|
orgs: {
|
||||||
some: {
|
some: {
|
||||||
orgId: user.activeOrgId,
|
orgId: activeOrg.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
orgs: {
|
orgs: {
|
||||||
where: {
|
where: {
|
||||||
orgId: user.activeOrgId,
|
orgId: activeOrg.id,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
role: true,
|
role: true,
|
||||||
|
|
@ -40,7 +58,7 @@ export default async function SettingsPage() {
|
||||||
|
|
||||||
const invites = await prisma.invite.findMany({
|
const invites = await prisma.invite.findMany({
|
||||||
where: {
|
where: {
|
||||||
orgId: user.activeOrgId,
|
orgId: activeOrg.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,7 +73,12 @@ export default async function SettingsPage() {
|
||||||
createdAt: invite.createdAt,
|
createdAt: invite.createdAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { user, memberInfo, inviteInfo };
|
return {
|
||||||
|
user,
|
||||||
|
memberInfo,
|
||||||
|
inviteInfo,
|
||||||
|
activeOrg,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await fetchData();
|
const data = await fetchData();
|
||||||
|
|
@ -71,7 +94,7 @@ export default async function SettingsPage() {
|
||||||
<h1 className="text-3xl">Settings</h1>
|
<h1 className="text-3xl">Settings</h1>
|
||||||
</Header>
|
</Header>
|
||||||
<div>
|
<div>
|
||||||
<MemberInviteForm orgId={user.activeOrgId!} userId={user.id} />
|
<MemberInviteForm userId={user.id} />
|
||||||
<InviteTable initialInvites={inviteInfo} />
|
<InviteTable initialInvites={inviteInfo} />
|
||||||
<MemberTable initialMembers={memberInfo} />
|
<MemberTable initialMembers={memberInfo} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -5,12 +5,13 @@ import { fileSourceResponseSchema, listRepositoriesResponseSchema, searchRespons
|
||||||
import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types";
|
import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types";
|
||||||
import assert from "assert";
|
import assert from "assert";
|
||||||
|
|
||||||
export const search = async (body: SearchRequest): Promise<SearchResponse> => {
|
export const search = async (body: SearchRequest, domain: string): Promise<SearchResponse> => {
|
||||||
const path = resolveServerPath("/api/search");
|
const path = resolveServerPath("/api/search");
|
||||||
const result = await fetch(path, {
|
const result = await fetch(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-Org-Domain": domain,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}).then(response => response.json());
|
}).then(response => response.json());
|
||||||
|
|
@ -18,12 +19,13 @@ export const search = async (body: SearchRequest): Promise<SearchResponse> => {
|
||||||
return searchResponseSchema.parse(result);
|
return searchResponseSchema.parse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchFileSource = async (body: FileSourceRequest): Promise<FileSourceResponse> => {
|
export const fetchFileSource = async (body: FileSourceRequest, domain: string): Promise<FileSourceResponse> => {
|
||||||
const path = resolveServerPath("/api/source");
|
const path = resolveServerPath("/api/source");
|
||||||
const result = await fetch(path, {
|
const result = await fetch(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-Org-Domain": domain,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}).then(response => response.json());
|
}).then(response => response.json());
|
||||||
|
|
@ -31,12 +33,13 @@ export const fetchFileSource = async (body: FileSourceRequest): Promise<FileSour
|
||||||
return fileSourceResponseSchema.parse(result);
|
return fileSourceResponseSchema.parse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getRepos = async (): Promise<ListRepositoriesResponse> => {
|
export const getRepos = async (domain: string): Promise<ListRepositoriesResponse> => {
|
||||||
const path = resolveServerPath("/api/repos");
|
const path = resolveServerPath("/api/repos");
|
||||||
const result = await fetch(path, {
|
const result = await fetch(path, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-Org-Domain": domain,
|
||||||
},
|
},
|
||||||
}).then(response => response.json());
|
}).then(response => response.json());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { listRepositories } from "@/lib/server/searchService";
|
import { listRepositories } from "@/lib/server/searchService";
|
||||||
import { getCurrentUserOrg } from "../../../../auth";
|
import { NextRequest } from "next/server";
|
||||||
|
import { withAuth, withOrgMembership } from "@/actions";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { serviceErrorResponse } from "@/lib/serviceError";
|
import { serviceErrorResponse } from "@/lib/serviceError";
|
||||||
|
|
||||||
export const GET = async () => {
|
export const GET = async (request: NextRequest) => {
|
||||||
const orgId = await getCurrentUserOrg();
|
const domain = request.headers.get("X-Org-Domain")!;
|
||||||
if (isServiceError(orgId)) {
|
const response = await getRepos(domain);
|
||||||
return serviceErrorResponse(orgId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await listRepositories(orgId);
|
if (isServiceError(response)) {
|
||||||
|
return serviceErrorResponse(response);
|
||||||
|
}
|
||||||
return Response.json(response);
|
return Response.json(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const getRepos = (domain: string) =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
const response = await listRepositories(orgId);
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
@ -2,18 +2,14 @@
|
||||||
|
|
||||||
import { search } from "@/lib/server/searchService";
|
import { search } from "@/lib/server/searchService";
|
||||||
import { searchRequestSchema } from "@/lib/schemas";
|
import { searchRequestSchema } from "@/lib/schemas";
|
||||||
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { getCurrentUserOrg } from "../../../../auth";
|
import { withAuth, withOrgMembership } from "@/actions";
|
||||||
|
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
||||||
|
import { SearchRequest } from "@/lib/types";
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
export const POST = async (request: NextRequest) => {
|
||||||
const orgId = await getCurrentUserOrg();
|
const domain = request.headers.get("X-Org-Domain")!;
|
||||||
if (isServiceError(orgId)) {
|
|
||||||
return serviceErrorResponse(orgId);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Searching for org ${orgId}`);
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = await searchRequestSchema.safeParseAsync(body);
|
const parsed = await searchRequestSchema.safeParseAsync(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|
@ -22,11 +18,16 @@ export const POST = async (request: NextRequest) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await postSearch(parsed.data, domain);
|
||||||
const response = await search(parsed.data, orgId);
|
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
return serviceErrorResponse(response);
|
return serviceErrorResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json(response);
|
return Response.json(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const postSearch = (request: SearchRequest, domain: string) =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
const response = await search(request, orgId);
|
||||||
|
return response;
|
||||||
|
}))
|
||||||
|
|
@ -5,14 +5,10 @@ import { getFileSource } from "@/lib/server/searchService";
|
||||||
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { getCurrentUserOrg } from "@/auth";
|
import { withAuth, withOrgMembership } from "@/actions";
|
||||||
|
import { FileSourceRequest } from "@/lib/types";
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
export const POST = async (request: NextRequest) => {
|
||||||
const orgId = await getCurrentUserOrg();
|
|
||||||
if (isServiceError(orgId)) {
|
|
||||||
return serviceErrorResponse(orgId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = await fileSourceRequestSchema.safeParseAsync(body);
|
const parsed = await fileSourceRequestSchema.safeParseAsync(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|
@ -21,10 +17,19 @@ export const POST = async (request: NextRequest) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getFileSource(parsed.data, orgId);
|
|
||||||
|
const response = await postSource(parsed.data, request.headers.get("X-Org-Domain")!);
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
return serviceErrorResponse(response);
|
return serviceErrorResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json(response);
|
return Response.json(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const postSource = (request: FileSourceRequest, domain: string) =>
|
||||||
|
withAuth(async (session) =>
|
||||||
|
withOrgMembership(session, domain, async (orgId) => {
|
||||||
|
const response = await getFileSource(request, orgId);
|
||||||
|
return response;
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { PlusCircledIcon } from "@radix-ui/react-icons";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(2).max(40),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
interface OrgCreationDialogProps {
|
|
||||||
onSubmit: (data: z.infer<typeof formSchema>) => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OrgCreationDialog = ({
|
|
||||||
onSubmit,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: OrgCreationDialogProps) => {
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="default"
|
|
||||||
className="w-full justify-start gap-1.5 p-0"
|
|
||||||
>
|
|
||||||
<PlusCircledIcon className="h-5 w-5 text-muted-foreground" />
|
|
||||||
Create organization
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create an organization</DialogTitle>
|
|
||||||
<DialogDescription>Organizations allow you to collaborate with team members.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Organization name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button className="mt-5" type="submit">Submit</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -56,7 +56,7 @@ export default async function Login(props: {
|
||||||
"use server"
|
"use server"
|
||||||
try {
|
try {
|
||||||
await signIn(provider.id, {
|
await signIn(provider.id, {
|
||||||
redirectTo: props.searchParams?.callbackUrl ?? "",
|
redirectTo: props.searchParams?.callbackUrl ?? "/",
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Signin can fail for a number of reasons, such as the user
|
// Signin can fail for a number of reasons, such as the user
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PageNotFound } from "./components/pageNotFound";
|
import { PageNotFound } from "./[domain]/components/pageNotFound";
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
98
packages/web/src/app/onboard/page.tsx
Normal file
98
packages/web/src/app/onboard/page.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import * as z from "zod"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||||
|
import { createOrg } from "@/actions"
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
organizationName: z.string().min(2, {
|
||||||
|
message: "Organization name must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
organizationDomain: z.string().regex(/^[a-z-]+$/, {
|
||||||
|
message: "Domain can only contain lowercase letters and dashes.",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function Onboard() {
|
||||||
|
const [_defaultDomain, setDefaultDomain] = useState("");
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
organizationName: "",
|
||||||
|
organizationDomain: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
createOrg(values.organizationName, values.organizationDomain)
|
||||||
|
.then(() => {
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const name = e.target.value
|
||||||
|
const domain = name.toLowerCase().replace(/\s+/g, "-")
|
||||||
|
setDefaultDomain(domain)
|
||||||
|
form.setValue("organizationDomain", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Organization</CardTitle>
|
||||||
|
<CardDescription>Enter your organization details below.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="organizationName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Organization Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Acme Inc"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e)
|
||||||
|
handleNameChange(e)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>{`This is your organization's full name.`}</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="organizationDomain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Organization Domain</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="acme-inc" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>{`This will be used for your organization's URL.`}</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,199 +1,35 @@
|
||||||
import { listRepositories } from "@/lib/server/searchService";
|
import { auth } from "@/auth";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { prisma } from "@/prisma";
|
||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
|
||||||
import logoDark from "@/public/sb_logo_dark_large.png";
|
|
||||||
import logoLight from "@/public/sb_logo_light_large.png";
|
|
||||||
import { NavigationMenu } from "./components/navigationMenu";
|
|
||||||
import { RepositoryCarousel } from "./components/repositoryCarousel";
|
|
||||||
import { SearchBar } from "./components/searchBar";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
|
||||||
import { UpgradeToast } from "./components/upgradeToast";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { getCurrentUserOrg } from "../auth"
|
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
export default async function Home() {
|
const session = await auth();
|
||||||
const orgId = await getCurrentUserOrg();
|
if (!session) {
|
||||||
|
return redirect("/login");
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
|
||||||
<NavigationMenu />
|
|
||||||
<UpgradeToast />
|
|
||||||
|
|
||||||
{isServiceError(orgId) ? (
|
|
||||||
<div className="mt-8 text-red-500">
|
|
||||||
You are not authenticated. Please log in to continue.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
|
||||||
<div className="max-h-44 w-auto">
|
|
||||||
<Image
|
|
||||||
src={logoDark}
|
|
||||||
className="h-18 md:h-40 w-auto hidden dark:block"
|
|
||||||
alt={"Sourcebot logo"}
|
|
||||||
priority={true}
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
src={logoLight}
|
|
||||||
className="h-18 md:h-40 w-auto block dark:hidden"
|
|
||||||
alt={"Sourcebot logo"}
|
|
||||||
priority={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SearchBar
|
|
||||||
autoFocus={true}
|
|
||||||
className="mt-4 w-full max-w-[800px]"
|
|
||||||
/>
|
|
||||||
<div className="mt-8">
|
|
||||||
<Suspense fallback={<div>...</div>}>
|
|
||||||
<RepositoryList orgId={orgId}/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center w-fit gap-6">
|
|
||||||
<Separator className="mt-5" />
|
|
||||||
<span className="font-semibold">How to search</span>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
||||||
<HowToSection
|
|
||||||
title="Search in files or paths"
|
|
||||||
>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="test todo">test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="test or todo">test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query={`"exit boot"`}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="TODO case:yes">TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
</HowToSection>
|
|
||||||
<HowToSection
|
|
||||||
title="Filter results"
|
|
||||||
>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="file:README setup"><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="repo:torvalds/linux test"><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="lang:typescript"><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="rev:HEAD"><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
</HowToSection>
|
|
||||||
<HowToSection
|
|
||||||
title="Advanced"
|
|
||||||
>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="file:\.py$"><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="sym:main"><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="todo -lang:c">todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="content:README"><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
</HowToSection>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<footer className="w-full mt-auto py-4 flex flex-row justify-center items-center gap-4">
|
|
||||||
<Link href="https://sourcebot.dev" className="text-gray-400 text-sm hover:underline">About</Link>
|
|
||||||
<Separator orientation="vertical" className="h-4" />
|
|
||||||
<Link href="https://github.com/sourcebot-dev/sourcebot/issues/new" className="text-gray-400 text-sm hover:underline">Support</Link>
|
|
||||||
<Separator orientation="vertical" className="h-4" />
|
|
||||||
<Link href="mailto:team@sourcebot.dev" className="text-gray-400 text-sm hover:underline">Contact Us</Link>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const RepositoryList = async ({ orgId }: { orgId: number}) => {
|
|
||||||
const _repos = await listRepositories(orgId);
|
|
||||||
|
|
||||||
if (isServiceError(_repos)) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const repos = _repos.List.Repos.map((repo) => repo.Repository);
|
const firstOrg = await prisma.userToOrg.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
org: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId: session.user.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
org: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
joinedAt: "asc"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (repos.length === 0) {
|
if (!firstOrg) {
|
||||||
return (
|
return redirect("/onboard");
|
||||||
<div className="flex flex-row items-center gap-3">
|
|
||||||
<SymbolIcon className="h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-sm">indexing in progress...</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return redirect(`/${firstOrg.org.domain}`);
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<span className="text-sm">
|
|
||||||
{`Search ${repos.length} `}
|
|
||||||
<Link
|
|
||||||
href="/repos"
|
|
||||||
className="text-blue-500"
|
|
||||||
>
|
|
||||||
{repos.length > 1 ? 'repositories' : 'repository'}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
<RepositoryCarousel repos={repos} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const Highlight = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<span className="text-highlight">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueryExample = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<span className="text-sm font-mono">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<span className="text-gray-500 dark:text-gray-400 ml-3">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Query = ({ query, children }: { query: string, children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/search?query=${query}`}
|
|
||||||
className="cursor-pointer hover:underline"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -9,45 +9,45 @@ import { Button } from "@/components/ui/button"
|
||||||
import { Invite } from "@sourcebot/db"
|
import { Invite } from "@sourcebot/db"
|
||||||
|
|
||||||
interface AcceptInviteButtonProps {
|
interface AcceptInviteButtonProps {
|
||||||
invite: Invite
|
invite: Invite
|
||||||
userId: string
|
userId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AcceptInviteButton({ invite, userId }: AcceptInviteButtonProps) {
|
export function AcceptInviteButton({ invite, userId }: AcceptInviteButtonProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const handleAcceptInvite = async () => {
|
const handleAcceptInvite = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await redeemInvite(invite, userId)
|
const res = await redeemInvite(invite, userId)
|
||||||
if (isServiceError(res)) {
|
if (isServiceError(res)) {
|
||||||
console.log("Failed to redeem invite: ", res)
|
console.log("Failed to redeem invite: ", res)
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "Failed to redeem invite. Please try again.",
|
description: "Failed to redeem invite. Please try again.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
router.push("/")
|
router.push("/")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error redeeming invite:", error)
|
console.error("Error redeeming invite:", error)
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "An unexpected error occurred. Please try again.",
|
description: "An unexpected error occurred. Please try again.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={handleAcceptInvite} disabled={isLoading}>
|
<Button onClick={handleAcceptInvite} disabled={isLoading}>
|
||||||
{isLoading ? "Accepting..." : "Accept Invite"}
|
{isLoading ? "Accepting..." : "Accept Invite"}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { NavigationMenu } from "../components/navigationMenu";
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { getUser } from "@/data/user";
|
import { getUser } from "@/data/user";
|
||||||
import { AcceptInviteButton } from "./components/acceptInviteButton"
|
import { AcceptInviteButton } from "./components/acceptInviteButton"
|
||||||
|
|
||||||
interface RedeemPageProps {
|
interface RedeemPageProps {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
invite_id?: string;
|
invite_id?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
|
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
|
||||||
const invite_id = searchParams?.invite_id;
|
const invite_id = searchParams?.invite_id;
|
||||||
|
|
||||||
if (!invite_id) {
|
if (!invite_id) {
|
||||||
|
|
@ -25,10 +24,9 @@ interface RedeemPageProps {
|
||||||
if (!invite) {
|
if (!invite) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<NavigationMenu />
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<h1>This invite either expired or was revoked. Contact your organization owner.</h1>
|
||||||
<h1>This invite either expired or was revoked. Contact your organization owner.</h1>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -45,10 +43,9 @@ interface RedeemPageProps {
|
||||||
if (user.email !== invite.recipientEmail) {
|
if (user.email !== invite.recipientEmail) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<NavigationMenu />
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<h1>Sorry this invite does not belong to you.</h1>
|
||||||
<h1>Sorry this invite does not belong to you.</h1>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -60,17 +57,15 @@ interface RedeemPageProps {
|
||||||
if (!orgName) {
|
if (!orgName) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<NavigationMenu />
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<h1>Organization not found. Please contact the invite sender.</h1>
|
||||||
<h1>Organization not found. Please contact the invite sender.</h1>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<NavigationMenu />
|
|
||||||
<div className="flex justify-between items-center h-screen px-6">
|
<div className="flex justify-between items-center h-screen px-6">
|
||||||
<h1 className="text-2xl font-bold">You have been invited to org {orgName.name}</h1>
|
<h1 className="text-2xl font-bold">You have been invited to org {orgName.name}</h1>
|
||||||
<AcceptInviteButton invite={invite} userId={user.id} />
|
<AcceptInviteButton invite={invite} userId={user.id} />
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
|
||||||
import { type ThemeProviderProps } from "next-themes/dist/types"
|
|
||||||
|
|
||||||
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
|
|
||||||
return (
|
|
||||||
<NextThemesProvider {...props}>
|
|
||||||
{children}
|
|
||||||
</NextThemesProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import 'next-auth/jwt';
|
import 'next-auth/jwt';
|
||||||
import NextAuth, { User as AuthJsUser, DefaultSession } from "next-auth"
|
import NextAuth, { DefaultSession } from "next-auth"
|
||||||
import GitHub from "next-auth/providers/github"
|
import GitHub from "next-auth/providers/github"
|
||||||
import Google from "next-auth/providers/google"
|
import Google from "next-auth/providers/google"
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter"
|
import { PrismaAdapter } from "@auth/prisma-adapter"
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import type { Provider } from "next-auth/providers"
|
import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET, AUTH_SECRET, AUTH_URL } from "./lib/environment";
|
||||||
import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET, AUTH_SECRET } from "./lib/environment";
|
|
||||||
import { User } from '@sourcebot/db';
|
import { User } from '@sourcebot/db';
|
||||||
import { notAuthenticated, notFound, unexpectedError } from "@/lib/serviceError";
|
import 'next-auth/jwt';
|
||||||
import { getUser } from "./data/user";
|
import type { Provider } from "next-auth/providers";
|
||||||
|
|
||||||
declare module 'next-auth' {
|
declare module 'next-auth' {
|
||||||
interface Session {
|
interface Session {
|
||||||
|
|
@ -20,9 +19,9 @@ declare module 'next-auth' {
|
||||||
|
|
||||||
declare module 'next-auth/jwt' {
|
declare module 'next-auth/jwt' {
|
||||||
interface JWT {
|
interface JWT {
|
||||||
userId: string
|
userId: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers: Provider[] = [
|
const providers: Provider[] = [
|
||||||
GitHub({
|
GitHub({
|
||||||
|
|
@ -47,46 +46,9 @@ export const providerMap = providers
|
||||||
})
|
})
|
||||||
.filter((provider) => provider.id !== "credentials");
|
.filter((provider) => provider.id !== "credentials");
|
||||||
|
|
||||||
const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
|
|
||||||
if (!user.id) {
|
|
||||||
throw new Error("User ID is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const orgName = (() => {
|
const useSecureCookies = AUTH_URL.startsWith("https://");
|
||||||
if (user.name) {
|
const hostName = new URL(AUTH_URL).hostname;
|
||||||
return `${user.name}'s Org`;
|
|
||||||
} else {
|
|
||||||
return `Default Org`;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
await prisma.$transaction((async (tx) => {
|
|
||||||
const org = await tx.org.create({
|
|
||||||
data: {
|
|
||||||
name: orgName,
|
|
||||||
members: {
|
|
||||||
create: {
|
|
||||||
role: "OWNER",
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: user.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.user.update({
|
|
||||||
where: {
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
activeOrgId: org.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
secret: AUTH_SECRET,
|
secret: AUTH_SECRET,
|
||||||
|
|
@ -94,9 +56,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
},
|
},
|
||||||
events: {
|
|
||||||
createUser: onCreateUser,
|
|
||||||
},
|
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user: _user }) {
|
async jwt({ token, user: _user }) {
|
||||||
const user = _user as User | undefined;
|
const user = _user as User | undefined;
|
||||||
|
|
@ -116,6 +75,37 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
id: token.userId,
|
id: token.userId,
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: `${useSecureCookies ? '__Secure-' : ''}authjs.session-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: useSecureCookies,
|
||||||
|
domain: `.${hostName}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
callbackUrl: {
|
||||||
|
name: `${useSecureCookies ? '__Secure-' : ''}authjs.callback-url`,
|
||||||
|
options: {
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: useSecureCookies,
|
||||||
|
domain: `.${hostName}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
csrfToken: {
|
||||||
|
name: `${useSecureCookies ? '__Host-' : ''}authjs.csrf-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: useSecureCookies,
|
||||||
|
domain: `.${hostName}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
providers: providers,
|
providers: providers,
|
||||||
|
|
@ -123,33 +113,3 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
signIn: "/login"
|
signIn: "/login"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getCurrentUserOrg = async () => {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session) {
|
|
||||||
return notAuthenticated();
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
|
||||||
if (!user) {
|
|
||||||
return unexpectedError("User not found");
|
|
||||||
}
|
|
||||||
const orgId = user.activeOrgId;
|
|
||||||
if (!orgId) {
|
|
||||||
return unexpectedError("User has no active org");
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await prisma.userToOrg.findUnique({
|
|
||||||
where: {
|
|
||||||
orgId_userId: {
|
|
||||||
userId: session.user.id,
|
|
||||||
orgId,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!membership) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return orgId;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
79
packages/web/src/components/ui/card.tsx
Normal file
79
packages/web/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
12
packages/web/src/data/org.ts
Normal file
12
packages/web/src/data/org.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { prisma } from '@/prisma';
|
||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
export const getOrgFromDomain = async (domain: string) => {
|
||||||
|
const org = await prisma.org.findUnique({
|
||||||
|
where: {
|
||||||
|
domain: domain
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return org;
|
||||||
|
}
|
||||||
8
packages/web/src/hooks/useDomain.ts
Normal file
8
packages/web/src/hooks/useDomain.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
export const useDomain = () => {
|
||||||
|
const { domain } = useParams<{ domain: string }>();
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
@ -12,3 +12,4 @@ export const AUTH_GITHUB_CLIENT_ID = getEnv(process.env.AUTH_GITHUB_CLIENT_ID);
|
||||||
export const AUTH_GITHUB_CLIENT_SECRET = getEnv(process.env.AUTH_GITHUB_CLIENT_SECRET);
|
export const AUTH_GITHUB_CLIENT_SECRET = getEnv(process.env.AUTH_GITHUB_CLIENT_SECRET);
|
||||||
export const AUTH_GOOGLE_CLIENT_ID = getEnv(process.env.AUTH_GOOGLE_CLIENT_ID);
|
export const AUTH_GOOGLE_CLIENT_ID = getEnv(process.env.AUTH_GOOGLE_CLIENT_ID);
|
||||||
export const AUTH_GOOGLE_CLIENT_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET);
|
export const AUTH_GOOGLE_CLIENT_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET);
|
||||||
|
export const AUTH_URL = getEnv(process.env.AUTH_URL)!;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import githubLogo from "../../public/github.svg";
|
import githubLogo from "@/public/github.svg";
|
||||||
import gitlabLogo from "../../public/gitlab.svg";
|
import gitlabLogo from "@/public/gitlab.svg";
|
||||||
import giteaLogo from "../../public/gitea.svg";
|
import giteaLogo from "@/public/gitea.svg";
|
||||||
import gerritLogo from "../../public/gerrit.svg";
|
import gerritLogo from "@/public/gerrit.svg";
|
||||||
import { ServiceError } from "./serviceError";
|
import { ServiceError } from "./serviceError";
|
||||||
import { Repository } from "./types";
|
import { Repository } from "./types";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,38 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "./auth"
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
export default auth((request) => {
|
||||||
import { Session } from "next-auth";
|
const host = request.headers.get("host")!;
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { notAuthenticated, serviceErrorResponse } from "./lib/serviceError";
|
|
||||||
|
|
||||||
interface NextAuthRequest extends NextRequest {
|
const searchParams = request.nextUrl.searchParams.toString();
|
||||||
auth: Session | null;
|
const path = `${request.nextUrl.pathname}${
|
||||||
}
|
searchParams.length > 0 ? `?${searchParams}` : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
const apiMiddleware = (req: NextAuthRequest) => {
|
if (
|
||||||
if (req.nextUrl.pathname.startsWith("/api/auth")) {
|
host === process.env.NEXT_PUBLIC_ROOT_DOMAIN ||
|
||||||
|
host === 'localhost:3000'
|
||||||
|
) {
|
||||||
|
if (request.nextUrl.pathname === "/login" && request.auth) {
|
||||||
|
return NextResponse.redirect(new URL("/", request.url));
|
||||||
|
}
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.auth) {
|
const subdomain = host.split(".")[0];
|
||||||
return serviceErrorResponse(
|
return NextResponse.rewrite(new URL(`/${subdomain}${path}`, request.url));
|
||||||
notAuthenticated(),
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultMiddleware = (req: NextAuthRequest) => {
|
|
||||||
// if we're trying to redeem an invite while not authed we continue to the redeem page so
|
|
||||||
// that we can pipe the invite_id to the login page
|
|
||||||
if (!req.auth && req.nextUrl.pathname === "/redeem") {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.auth && req.nextUrl.pathname !== "/login") {
|
|
||||||
const newUrl = new URL("/login", req.nextUrl.origin);
|
|
||||||
return NextResponse.redirect(newUrl);
|
|
||||||
} else if (req.auth && req.nextUrl.pathname === "/login") {
|
|
||||||
const newUrl = new URL("/", req.nextUrl.origin);
|
|
||||||
return NextResponse.redirect(newUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default auth(async (req) => {
|
|
||||||
if (req.nextUrl.pathname.startsWith("/api")) {
|
|
||||||
return apiMiddleware(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultMiddleware(req);
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
|
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
|
||||||
matcher: ['/((?!_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
|
matcher: [
|
||||||
|
/**
|
||||||
|
* Match all paths except for:
|
||||||
|
* 1. /api routes
|
||||||
|
* 2. _next/ routes
|
||||||
|
* 3. ingest (PostHog route)
|
||||||
|
*/
|
||||||
|
'/((?!api|_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
@ -141,10 +141,6 @@
|
||||||
["docs", "core"]
|
["docs", "core"]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"tenantId": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "@nocheckin"
|
|
||||||
},
|
|
||||||
"exclude": {
|
"exclude": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ redirect_stderr=true
|
||||||
|
|
||||||
|
|
||||||
[program:backend]
|
[program:backend]
|
||||||
command=./prefix-output.sh node packages/backend/dist/index.js --configPath %(ENV_CONFIG_PATH)s --cacheDir %(ENV_DATA_CACHE_DIR)s
|
command=./prefix-output.sh node packages/backend/dist/index.js --cacheDir %(ENV_DATA_CACHE_DIR)s
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
startretries=3
|
startretries=3
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue