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