add concept of secrets (#180)

* add @sourcebot/schemas package

* migrate things to use the schemas package

* Dockerfile support

* add secret table to schema

* Add concept of connection manager

* Rename Config->Connection

* Handle job failures

* Add join table between repo and connection

* nits

* create first version of crypto package

* add crypto package as deps to others

* forgot to add package changes

* add server action for adding and listing secrets, create test page for it

* add secrets page to nav menu

* add secret to config and support fetching it in backend

* reset secret form on successful submission

* add toast feedback for secrets form

* add instructions for adding encryption key to dev instructions

* add encryption key support in docker file

* add delete secret button

* fix nits from pr review

---------

Co-authored-by: bkellam <bshizzle1234@gmail.com>
This commit is contained in:
Michael Sukkarieh 2025-01-27 14:07:07 -08:00 committed by GitHub
parent dd8ff6edb0
commit 31114a9d95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 699 additions and 31 deletions

View file

@ -17,8 +17,10 @@ WORKDIR /app
COPY package.json yarn.lock* ./
COPY ./packages/db ./packages/db
COPY ./packages/schemas ./packages/schemas
COPY ./packages/crypto ./packages/crypto
RUN yarn workspace @sourcebot/db install --frozen-lockfile
RUN yarn workspace @sourcebot/schemas install --frozen-lockfile
RUN yarn workspace @sourcebot/crypto install --frozen-lockfile
# ------ Build Web ------
FROM node-alpine AS web-builder
@ -30,6 +32,7 @@ COPY ./packages/web ./packages/web
COPY --from=shared-libs-builder /app/node_modules ./node_modules
COPY --from=shared-libs-builder /app/packages/db ./packages/db
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
# Fixes arm64 timeouts
RUN yarn config set registry https://registry.npmjs.org/
@ -60,6 +63,7 @@ COPY ./packages/backend ./packages/backend
COPY --from=shared-libs-builder /app/node_modules ./node_modules
COPY --from=shared-libs-builder /app/packages/db ./packages/db
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
RUN yarn workspace @sourcebot/backend install --frozen-lockfile
RUN yarn workspace @sourcebot/backend build
@ -100,7 +104,7 @@ ENV POSTHOG_PAPIK=$POSTHOG_PAPIK
# ENV SOURCEBOT_TELEMETRY_DISABLED=1
# Configure dependencies
RUN apk add --no-cache git ca-certificates bind-tools tini jansson wget supervisor uuidgen curl perl jq redis postgresql postgresql-contrib
RUN apk add --no-cache git ca-certificates bind-tools tini jansson wget supervisor uuidgen curl perl jq redis postgresql postgresql-contrib openssl
# Configure zoekt
COPY vendor/zoekt/install-ctags-alpine.sh .
@ -129,6 +133,7 @@ COPY --from=backend-builder /app/packages/backend ./packages/backend
COPY --from=shared-libs-builder /app/node_modules ./node_modules
COPY --from=shared-libs-builder /app/packages/db ./packages/db
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
# Configure the database
RUN mkdir -p /run/postgresql && \
@ -143,6 +148,8 @@ RUN chmod +x ./entrypoint.sh
COPY default-config.json .
ENV SOURCEBOT_ENCRYPTION_KEY=""
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

View file

@ -374,14 +374,20 @@ docker run <b>-v /path/to/my-repo:/repos/my-repo</b> /* additional args */ ghcr.
5. Create a `config.json` file at the repository root. See [Configuring Sourcebot](#configuring-sourcebot) for more information.
6. Start Sourcebot with the command:
6. Create `.env.local` files in the `packages/backend` and `packages/web` directories with the following contents:
```sh
# You can use https://acte.ltd/utils/randomkeygen to generate a key ("Encryption key 256")
SOURCEBOT_ENCRYPTION_KEY="32-byte-secret-key"
```
7. Start Sourcebot with the command:
```sh
yarn dev
```
A `.sourcebot` directory will be created and zoekt will begin to index the repositories found given `config.json`.
7. Start searching at `http://localhost:3000`.
8. Start searching at `http://localhost:3000`.
## Telemetry

View file

@ -26,6 +26,22 @@ if [ ! -d "$DB_DATA_DIR" ]; then
su postgres -c "initdb -D $DB_DATA_DIR"
fi
if [ -z "$SOURCEBOT_ENCRYPTION_KEY" ]; then
echo -e "\e[31m[Error] SOURCEBOT_ENCRYPTION_KEY is not set.\e[0m"
if [ -f "$DATA_CACHE_DIR/.secret" ]; then
echo -e "\e[34m[Info] Loading environment variables from $DATA_CACHE_DIR/.secret\e[0m"
else
echo -e "\e[34m[Info] Generating a new encryption key...\e[0m"
SOURCEBOT_ENCRYPTION_KEY=$(openssl rand -base64 24)
echo "SOURCEBOT_ENCRYPTION_KEY=\"$SOURCEBOT_ENCRYPTION_KEY\"" >> "$DATA_CACHE_DIR/.secret"
fi
set -a
. "$DATA_CACHE_DIR/.secret"
set +a
fi
# In order to detect if this is the first run, we create a `.installed` file in
# the cache directory.
FIRST_RUN_FILE="$DATA_CACHE_DIR/.installedv2"

View file

@ -31,6 +31,7 @@
"lowdb": "^7.0.1",
"micromatch": "^4.0.8",
"posthog-node": "^4.2.1",
"@sourcebot/crypto": "^0.1.0",
"@sourcebot/db": "^0.1.0",
"@sourcebot/schemas": "^0.1.0",
"simple-git": "^3.27.0",

View file

@ -5,7 +5,7 @@ import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { createLogger } from "./logger.js";
import os from 'os';
import { Redis } from 'ioredis';
import { getTokenFromConfig, marshalBool } from "./utils.js";
import { marshalBool } from "./utils.js";
import { getGitHubReposFromConfig } from "./github.js";
interface IConnectionManager {
@ -70,17 +70,13 @@ export class ConnectionManager implements IConnectionManager {
const repoData: RepoData[] = await (async () => {
switch (config.type) {
case 'github': {
const token = config.token ? getTokenFromConfig(config.token, this.context) : undefined;
const gitHubRepos = await getGitHubReposFromConfig(config, abortController.signal, this.context);
const gitHubRepos = await getGitHubReposFromConfig(config, orgId, this.db, abortController.signal);
const hostUrl = config.url ?? 'https://github.com';
const hostname = config.url ? new URL(config.url).hostname : 'github.com';
return gitHubRepos.map((repo) => {
const repoName = `${hostname}/${repo.full_name}`;
const cloneUrl = new URL(repo.clone_url!);
if (token) {
cloneUrl.username = token;
}
const record: RepoData = {
external_id: repo.id.toString(),

View file

@ -9,8 +9,9 @@ import micromatch from 'micromatch';
const logger = createLogger('Gitea');
export const getGiteaReposFromConfig = async (config: GiteaConfig, ctx: AppContext) => {
const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined;
export const getGiteaReposFromConfig = async (config: GiteaConfig, orgId: number, ctx: AppContext) => {
// TODO: pass in DB here to fetch secret properly
const token = config.token ? await getTokenFromConfig(config.token, orgId) : undefined;
const api = giteaApi(config.url ?? 'https://gitea.com', {
token,

View file

@ -4,6 +4,7 @@ import { createLogger } from "./logger.js";
import { AppContext } from "./types.js";
import { getTokenFromConfig, measure } from "./utils.js";
import micromatch from "micromatch";
import { PrismaClient } from "@sourcebot/db";
const logger = createLogger("GitHub");
@ -25,8 +26,8 @@ export type OctokitRepository = {
size?: number,
}
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, signal: AbortSignal, ctx: AppContext) => {
const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined;
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {
const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
const octokit = new Octokit({
auth: token,

View file

@ -8,8 +8,9 @@ import { getTokenFromConfig, measure } from "./utils.js";
const logger = createLogger("GitLab");
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
export const getGitLabReposFromConfig = async (config: GitLabConfig, ctx: AppContext) => {
const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined;
export const getGitLabReposFromConfig = async (config: GitLabConfig, orgId: number, ctx: AppContext) => {
// TODO: pass in DB here to fetch secret properly
const token = config.token ? await getTokenFromConfig(config.token, orgId) : undefined;
const api = new Gitlab({
...(config.token ? {
token,

View file

@ -1,20 +1,50 @@
import { ConnectionSyncStatus, PrismaClient, Repo, RepoIndexingStatus } from '@sourcebot/db';
import { ConnectionSyncStatus, PrismaClient, Repo, RepoIndexingStatus, RepoToConnection, Connection } from '@sourcebot/db';
import { existsSync } from 'fs';
import { cloneRepository, fetchRepository } from "./git.js";
import { createLogger } from "./logger.js";
import { captureEvent } from "./posthog.js";
import { AppContext } from "./types.js";
import { getRepoPath, measure } from "./utils.js";
import { getRepoPath, getTokenFromConfig, measure } from "./utils.js";
import { indexGitRepository } from "./zoekt.js";
import { DEFAULT_SETTINGS } from './constants.js';
import { Queue, Worker, Job } from 'bullmq';
import { Redis } from 'ioredis';
import * as os from 'os';
import { ConnectionManager } from './connectionManager.js';
import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
const logger = createLogger('main');
const syncGitRepository = async (repo: Repo, ctx: AppContext) => {
type RepoWithConnections = Repo & { connections: (RepoToConnection & { connection: Connection})[] };
// TODO: do this better? ex: try using the tokens from all the connections
// We can no longer use repo.cloneUrl directly since it doesn't contain the token for security reasons. As a result, we need to
// fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each
// may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This
// may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referrencing.
const getTokenForRepo = async (repo: RepoWithConnections, db: PrismaClient) => {
const repoConnections = repo.connections;
if (repoConnections.length === 0) {
logger.error(`Repo ${repo.id} has no connections`);
return;
}
let token: string | undefined;
for (const repoConnection of repoConnections) {
const connection = repoConnection.connection;
const config = connection.config as unknown as ConnectionConfig;
if (config.token) {
token = await getTokenFromConfig(config.token, connection.orgId, db);
if (token) {
break;
}
}
}
return token;
}
const syncGitRepository = async (repo: RepoWithConnections, ctx: AppContext, db: PrismaClient) => {
let fetchDuration_s: number | undefined = undefined;
let cloneDuration_s: number | undefined = undefined;
@ -35,7 +65,15 @@ const syncGitRepository = async (repo: Repo, ctx: AppContext) => {
} else {
logger.info(`Cloning ${repo.id}...`);
const { durationMs } = await measure(() => cloneRepository(repo.cloneUrl, repoPath, metadata, ({ method, stage, progress }) => {
const token = await getTokenForRepo(repo, db);
let cloneUrl = repo.cloneUrl;
if (token) {
const url = new URL(cloneUrl);
url.username = token;
cloneUrl = url.toString();
}
const { durationMs } = await measure(() => cloneRepository(cloneUrl, repoPath, metadata, ({ method, stage, progress }) => {
logger.info(`git.${method} ${stage} stage ${progress}% complete for ${repo.id}`)
}));
cloneDuration_s = durationMs / 1000;
@ -92,13 +130,13 @@ export const main = async (db: PrismaClient, context: AppContext) => {
const connectionManager = new ConnectionManager(db, DEFAULT_SETTINGS, redis, context);
setInterval(async () => {
const configs = await db.connection.findMany({
const connections = await db.connection.findMany({
where: {
syncStatus: ConnectionSyncStatus.SYNC_NEEDED,
}
});
for (const config of configs) {
await connectionManager.scheduleConnectionSync(config);
for (const connection of connections) {
await connectionManager.scheduleConnectionSync(connection);
}
}, DEFAULT_SETTINGS.resyncConnectionPollingIntervalMs);
@ -111,13 +149,13 @@ export const main = async (db: PrismaClient, context: AppContext) => {
const numWorkers = numCores * DEFAULT_SETTINGS.indexConcurrencyMultiple;
logger.info(`Detected ${numCores} cores. Setting repo index max concurrency to ${numWorkers}`);
const worker = new Worker('indexQueue', async (job: Job) => {
const repo = job.data as Repo;
const repo = job.data as RepoWithConnections;
let indexDuration_s: number | undefined;
let fetchDuration_s: number | undefined;
let cloneDuration_s: number | undefined;
const stats = await syncGitRepository(repo, context);
const stats = await syncGitRepository(repo, context, db);
indexDuration_s = stats.indexDuration_s;
fetchDuration_s = stats.fetchDuration_s;
cloneDuration_s = stats.cloneDuration_s;
@ -171,6 +209,13 @@ export const main = async (db: PrismaClient, context: AppContext) => {
{ indexedAt: { lt: thresholdDate } },
{ repoIndexingStatus: RepoIndexingStatus.NEW }
]
},
include: {
connections: {
include: {
connection: true
}
}
}
});
addReposToQueue(db, indexQueue, repos);

View file

@ -2,7 +2,9 @@ import { Logger } from "winston";
import { AppContext, Repository } from "./types.js";
import path from 'path';
import micromatch from "micromatch";
import { Repo } from "@sourcebot/db";
import { PrismaClient, Repo } from "@sourcebot/db";
import { decrypt } from "@sourcebot/crypto";
import { Token } from "@sourcebot/schemas/v3/shared.type";
export const measure = async <T>(cb : () => Promise<T>) => {
const start = Date.now();
@ -86,15 +88,39 @@ export const excludeReposByTopic = <T extends Repository>(repos: T[], excludedRe
});
}
export const getTokenFromConfig = (token: string | { env: string }, ctx: AppContext) => {
export const getTokenFromConfig = async (token: Token, orgId: number, db?: PrismaClient) => {
if (typeof token === 'string') {
return token;
}
const tokenValue = process.env[token.env];
if (!tokenValue) {
throw new Error(`The environment variable '${token.env}' was referenced in ${ctx.configPath}, but was not set.`);
if ('env' in token) {
const tokenValue = process.env[token.env];
if (!tokenValue) {
throw new Error(`The environment variable '${token.env}' was referenced in the config but was not set.`);
}
return tokenValue;
} else if ('secret' in token) {
if (!db) {
throw new Error(`Database connection required to retrieve secret`);
}
const secretKey = token.secret;
const secret = await db.secret.findUnique({
where: {
orgId_key: {
key: secretKey,
orgId
}
}
});
if (!secret) {
throw new Error(`Secret with key ${secretKey} not found for org ${orgId}`);
}
const decryptedSecret = decrypt(secret.iv, secret.encryptedValue);
return decryptedSecret;
}
return tokenValue;
throw new Error(`Invalid token configuration in config`);
}
export const isRemotePath = (path: string) => {

1
packages/crypto/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env.local

View file

@ -0,0 +1,16 @@
{
"name": "@sourcebot/crypto",
"main": "dist/index.js",
"version": "0.1.0",
"scripts": {
"build": "tsc",
"postinstall": "yarn build"
},
"dependencies": {
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/node": "^22.7.5",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,17 @@
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`);
}
return env ?? defaultValue;
}
dotenv.config({
path: './.env.local',
override: true
});
// @note: You can use https://generate-random.org/encryption-key-generator to create a new 32 byte key
export const SOURCEBOT_ENCRYPTION_KEY = getEnv(process.env.SOURCEBOT_ENCRYPTION_KEY, undefined, true)!;

View file

@ -0,0 +1,35 @@
import crypto from 'crypto';
import { SOURCEBOT_ENCRYPTION_KEY } from './environment';
const algorithm = 'aes-256-cbc';
const ivLength = 16; // 16 bytes for CBC
const generateIV = (): Buffer => {
return crypto.randomBytes(ivLength);
};
export function encrypt(text: string): { iv: string; encryptedData: string } {
const encryptionKey = Buffer.from(SOURCEBOT_ENCRYPTION_KEY, 'ascii');
const iv = generateIV();
const cipher = crypto.createCipheriv(algorithm, encryptionKey, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return { iv: iv.toString('hex'), encryptedData: encrypted };
}
export function decrypt(iv: string, encryptedText: string): string {
const encryptionKey = Buffer.from(SOURCEBOT_ENCRYPTION_KEY, 'ascii');
const ivBuffer = Buffer.from(iv, 'hex');
const encryptedBuffer = Buffer.from(encryptedText, 'hex');
const decipher = crypto.createDecipheriv(algorithm, encryptionKey, ivBuffer);
let decrypted = decipher.update(encryptedBuffer, undefined, 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"lib": ["ES6"],
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "Secret" (
"orgId" INTEGER NOT NULL,
"key" TEXT NOT NULL,
"encryptedValue" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Secret_pkey" PRIMARY KEY ("orgId","key")
);
-- AddForeignKey
ALTER TABLE "Secret" ADD CONSTRAINT "Secret_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -88,6 +88,7 @@ model Org {
members UserToOrg[]
connections Connection[]
repos Repo[]
secrets Secret[]
}
enum OrgRole {
@ -111,6 +112,19 @@ model UserToOrg {
@@id([orgId, userId])
}
model Secret {
orgId Int
key String
encryptedValue String
iv String
createdAt DateTime @default(now())
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@id([orgId, key])
}
// @see : https://authjs.dev/concepts/database-models#user
model User {
id String @id @default(cuid())

View file

@ -36,6 +36,19 @@ const schema = {
"env"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"secret": {
"type": "string",
"description": "The name of the secret that contains the token."
}
},
"required": [
"secret"
],
"additionalProperties": false
}
]
},

View file

@ -17,6 +17,12 @@ export interface GithubConnectionConfig {
* The name of the environment variable that contains the token.
*/
env: string;
}
| {
/**
* The name of the secret that contains the token.
*/
secret: string;
};
/**
* The URL of the GitHub host. Defaults to https://github.com

View file

@ -32,6 +32,19 @@ const schema = {
"env"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"secret": {
"type": "string",
"description": "The name of the secret that contains the token."
}
},
"required": [
"secret"
],
"additionalProperties": false
}
]
},

View file

@ -15,6 +15,12 @@ export interface GithubConnectionConfig {
* The name of the environment variable that contains the token.
*/
env: string;
}
| {
/**
* The name of the secret that contains the token.
*/
secret: string;
};
/**
* The URL of the GitHub host. Defaults to https://github.com

View file

@ -20,6 +20,19 @@ const schema = {
"env"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"secret": {
"type": "string",
"description": "The name of the secret that contains the token."
}
},
"required": [
"secret"
],
"additionalProperties": false
}
]
},

View file

@ -11,6 +11,12 @@ export type Token =
* The name of the environment variable that contains the token.
*/
env: string;
}
| {
/**
* The name of the secret that contains the token.
*/
secret: string;
};
export interface Shared {

View file

@ -58,6 +58,7 @@
"@replit/codemirror-lang-svelte": "^6.0.0",
"@replit/codemirror-vim": "^6.2.1",
"@shopify/lang-jsonc": "^1.0.0",
"@sourcebot/crypto": "^0.1.0",
"@sourcebot/schemas": "^0.1.0",
"@sourcebot/db": "^0.1.0",
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",

View file

@ -8,11 +8,144 @@ import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "./lib/errorCodes";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { encrypt } from "@sourcebot/crypto"
const ajv = new Ajv({
validateFormats: false,
});
export const createSecret = async (key: string, value: string): Promise<{ success: boolean } | ServiceError> => {
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");
}
// @todo: refactor this into a shared function
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: session.user.id,
orgId,
}
},
});
if (!membership) {
return notFound();
}
try {
const encrypted = encrypt(value);
await prisma.secret.create({
data: {
orgId,
key,
encryptedValue: encrypted.encryptedData,
iv: encrypted.iv,
}
});
} catch (e) {
return unexpectedError(`Failed to create secret`);
}
return {
success: true,
}
}
export const getSecrets = async (): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => {
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();
}
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 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();
}
await prisma.secret.delete({
where: {
orgId_key: {
orgId,
key,
}
}
});
return {
success: true,
}
}
export const createOrg = async (name: string): Promise<{ id: number } | ServiceError> => {
const session = await auth();
if (!session) {

View file

@ -56,6 +56,13 @@ export const NavigationMenu = async () => {
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/secrets" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Secrets
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenuBase>
</div>

View file

@ -0,0 +1,59 @@
'use client';
import { Button } from "@/components/ui/button";
import { Column, ColumnDef } from "@tanstack/react-table"
import { ArrowUpDown } from "lucide-react"
export type SecretColumnInfo = {
key: string;
createdAt: string;
}
export const columns = (handleDelete: (key: string) => void): ColumnDef<SecretColumnInfo>[] => {
return [
{
accessorKey: "key",
cell: ({ row }) => {
const secret = row.original;
return <div>{secret.key}</div>;
}
},
{
accessorKey: "createdAt",
header: ({ column }) => createSortHeader("Created At", column),
cell: ({ row }) => {
const secret = row.original;
return <div>{secret.createdAt}</div>;
}
},
{
accessorKey: "delete",
cell: ({ row }) => {
const secret = row.original;
return (
<Button
variant="destructive"
onClick={() => {
handleDelete(secret.key);
}}
>
Delete
</Button>
)
}
}
]
}
const createSortHeader = (name: string, column: Column<SecretColumnInfo, unknown>) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{name}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
}

View file

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

View file

@ -0,0 +1,147 @@
'use client';
import { useEffect, useMemo, useState } from "react";
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";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
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";
const formSchema = z.object({
key: z.string().min(2).max(40),
value: z.string().min(2).max(40),
});
export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets);
const { toast } = useToast();
const fetchSecretKeys = async () => {
const keys = await getSecrets();
if ('keys' in keys) {
setSecrets(keys);
} else {
console.error(keys);
}
};
useEffect(() => {
fetchSecretKeys();
}, []);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
key: "",
value: "",
},
});
const handleCreateSecret = async (values: { key: string, value: string }) => {
const res = await createSecret(values.key, values.value);
if (isServiceError(res)) {
toast({
description: `❌ Failed to create secret`
});
return;
} else {
toast({
description: `✅ Secret created successfully!`
});
}
const keys = await getSecrets();
if (isServiceError(keys)) {
console.error("Failed to fetch secrets");
} else {
setSecrets(keys);
form.reset();
form.resetField("key");
form.resetField("value");
}
};
const handleDelete = async (key: string) => {
const res = await deleteSecret(key);
if (isServiceError(res)) {
toast({
description: `❌ Failed to delete secret`
});
return;
} else {
toast({
description: `✅ Secret deleted successfully!`
});
}
const keys = await getSecrets();
if ('keys' in keys) {
setSecrets(keys);
} else {
console.error(keys);
}
};
const keys = useMemo(() => {
return secrets.map((secret): SecretColumnInfo => {
return {
key: secret.key,
createdAt: secret.createdAt.toISOString(),
}
}).sort((a, b) => {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}, [secrets]);
return (
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleCreateSecret)}>
<FormField
control={form.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Value</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-5" type="submit">Submit</Button>
</form>
</Form>
<DataTable
columns={columns(handleDelete)}
data={keys}
searchKey="key"
searchPlaceholder="Search secrets..."
/>
</div>
);
};

View file

@ -98,6 +98,15 @@ export const fileSourceResponseSchema = z.object({
language: z.string(),
});
export const secretCreateRequestSchema = z.object({
key: z.string(),
value: z.string(),
});
export const secreteDeleteRequestSchema = z.object({
key: z.string(),
});
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728
const repoStatsSchema = z.object({

View file

@ -19,6 +19,19 @@
"env"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"secret": {
"type": "string",
"description": "The name of the secret that contains the token."
}
},
"required": [
"secret"
],
"additionalProperties": false
}
]
},