diff --git a/.env.development b/.env.development index 39b25125c..e9029f539 100644 --- a/.env.development +++ b/.env.development @@ -71,3 +71,5 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection NODE_ENV=development DEBUG_WRITE_CHAT_MESSAGES_TO_FILE=true + +SOURCEBOT_LIGHTHOUSE_URL=http://localhost:3003 diff --git a/CHANGELOG.md b/CHANGELOG.md index 483761848..458b5b154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Redesigned the app layout with a new collapsible sidebar navigation, replacing the previous top navigation bar. [#1097](https://github.com/sourcebot-dev/sourcebot/pull/1097) +- Expired offline license keys no longer crash the process. An expired key now degrades to the unlicensed state. [#1109](https://github.com/sourcebot-dev/sourcebot/pull/1109) ## [4.16.9] - 2026-04-15 diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index b1a4b602e..1eccf3e11 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -1025,8 +1025,7 @@ "type": "string", "enum": [ "OWNER", - "MEMBER", - "GUEST" + "MEMBER" ] }, "createdAt": { diff --git a/packages/backend/src/__mocks__/prisma.ts b/packages/backend/src/__mocks__/prisma.ts new file mode 100644 index 000000000..7a07a85b4 --- /dev/null +++ b/packages/backend/src/__mocks__/prisma.ts @@ -0,0 +1,7 @@ +import { vi } from 'vitest'; + +export const prisma = { + license: { + findUnique: vi.fn().mockResolvedValue(null), + }, +}; diff --git a/packages/backend/src/api.ts b/packages/backend/src/api.ts index 0df9ee20a..fda71ac44 100644 --- a/packages/backend/src/api.ts +++ b/packages/backend/src/api.ts @@ -1,5 +1,6 @@ import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db'; -import { createLogger, env, hasEntitlement, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared'; +import { createLogger, env, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared'; +import { hasEntitlement } from './entitlements.js'; import express, { Request, Response } from 'express'; import 'express-async-errors'; import * as http from "http"; @@ -100,7 +101,7 @@ export class Api { } private async triggerAccountPermissionSync(req: Request, res: Response) { - if (env.PERMISSION_SYNC_ENABLED !== 'true' || !hasEntitlement('permission-syncing')) { + if (env.PERMISSION_SYNC_ENABLED !== 'true' || !await hasEntitlement('permission-syncing')) { res.status(403).json({ error: 'Permission syncing is not enabled.' }); return; } diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index f1bfe9d05..9b88cd5ce 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -1,6 +1,7 @@ import * as Sentry from "@sentry/node"; import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db"; -import { env, hasEntitlement, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; +import { env, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { ensureFreshAccountToken } from "./tokenRefresh.js"; import { Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; @@ -50,8 +51,8 @@ export class AccountPermissionSyncer { this.worker.on('failed', this.onJobFailed.bind(this)); } - public startScheduler() { - if (!hasEntitlement('permission-syncing')) { + public async startScheduler() { + if (!await hasEntitlement('permission-syncing')) { throw new Error('Permission syncing is not supported in current plan.'); } diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index a6f42bf17..befb8593e 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -1,7 +1,8 @@ import * as Sentry from "@sentry/node"; import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db"; import { createLogger, PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "@sourcebot/shared"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js"; @@ -44,8 +45,8 @@ export class RepoPermissionSyncer { this.worker.on('failed', this.onJobFailed.bind(this)); } - public startScheduler() { - if (!hasEntitlement('permission-syncing')) { + public async startScheduler() { + if (!await hasEntitlement('permission-syncing')) { throw new Error('Permission syncing is not supported in current plan.'); } diff --git a/packages/backend/src/ee/syncSearchContexts.test.ts b/packages/backend/src/ee/syncSearchContexts.test.ts index bfd2f8b1f..9aa1decfd 100644 --- a/packages/backend/src/ee/syncSearchContexts.test.ts +++ b/packages/backend/src/ee/syncSearchContexts.test.ts @@ -12,12 +12,15 @@ vi.mock('@sourcebot/shared', async (importOriginal) => { error: vi.fn(), debug: vi.fn(), })), - hasEntitlement: vi.fn(() => true), - getPlan: vi.fn(() => 'enterprise'), SOURCEBOT_SUPPORT_EMAIL: 'support@sourcebot.dev', }; }); +vi.mock('../entitlements.js', () => ({ + hasEntitlement: vi.fn(() => Promise.resolve(true)), + getPlan: vi.fn(() => Promise.resolve('enterprise')), +})); + import { syncSearchContexts } from './syncSearchContexts.js'; // Helper to build a repo record with GitLab topics stored in metadata. diff --git a/packages/backend/src/ee/syncSearchContexts.ts b/packages/backend/src/ee/syncSearchContexts.ts index cd745a356..e186a777a 100644 --- a/packages/backend/src/ee/syncSearchContexts.ts +++ b/packages/backend/src/ee/syncSearchContexts.ts @@ -1,7 +1,8 @@ import micromatch from "micromatch"; import { createLogger } from "@sourcebot/shared"; import { PrismaClient } from "@sourcebot/db"; -import { getPlan, hasEntitlement, repoMetadataSchema, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared"; +import { repoMetadataSchema, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { SearchContext } from "@sourcebot/schemas/v3/index.type"; const logger = createLogger('sync-search-contexts'); @@ -15,10 +16,9 @@ interface SyncSearchContextsParams { export const syncSearchContexts = async (params: SyncSearchContextsParams) => { const { contexts, orgId, db } = params; - if (!hasEntitlement("search-contexts")) { + if (!await hasEntitlement("search-contexts")) { if (contexts) { - const plan = getPlan(); - logger.warn(`Skipping search context sync. Reason: "Search contexts are not supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}."`); + logger.warn(`Skipping search context sync. Reason: "Search contexts are not supported in your current plan. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}."`); } return false; } diff --git a/packages/backend/src/entitlements.ts b/packages/backend/src/entitlements.ts new file mode 100644 index 000000000..7959cd79b --- /dev/null +++ b/packages/backend/src/entitlements.ts @@ -0,0 +1,23 @@ +import { + Entitlement, + _hasEntitlement, + _getEntitlements, +} from "@sourcebot/shared"; +import { prisma } from "./prisma.js"; +import { SINGLE_TENANT_ORG_ID } from "./constants.js"; + +const getLicense = async () => { + return prisma.license.findUnique({ + where: { orgId: SINGLE_TENANT_ORG_ID }, + }); +} + +export const hasEntitlement = async (entitlement: Entitlement): Promise => { + const license = await getLicense(); + return _hasEntitlement(entitlement, license); +} + +export const getEntitlements = async (): Promise => { + const license = await getLicense(); + return _getEntitlements(license); +} diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index f82bb2282..998563408 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -4,7 +4,8 @@ import * as Sentry from "@sentry/node"; import { getTokenFromConfig } from "@sourcebot/shared"; import { createLogger } from "@sourcebot/shared"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; import micromatch from "micromatch"; import pLimit from "p-limit"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; @@ -124,7 +125,7 @@ const getOctokitWithGithubApp = async ( url: string | undefined, context: string ): Promise => { - if (!hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) { + if (!await hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) { return octokit; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0999f7771..af3fd489c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,8 +1,9 @@ import "./instrument.js"; import * as Sentry from "@sentry/node"; -import { PrismaClient } from "@sourcebot/db"; -import { createLogger, env, getConfigSettings, getDBConnectionString, hasEntitlement } from "@sourcebot/shared"; +import { createLogger, env, getConfigSettings } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; +import { prisma } from "./prisma.js"; import 'express-async-errors'; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; @@ -31,13 +32,6 @@ if (!existsSync(indexPath)) { await mkdir(indexPath, { recursive: true }); } -const prisma = new PrismaClient({ - datasources: { - db: { - url: getDBConnectionString(), - }, - }, -}); try { await redis.ping(); @@ -51,7 +45,7 @@ const promClient = new PromClient(); const settings = await getConfigSettings(env.CONFIG_PATH); -if (hasEntitlement('github-app')) { +if (await hasEntitlement('github-app')) { await GithubAppManager.getInstance().init(prisma); } @@ -66,11 +60,11 @@ connectionManager.startScheduler(); await repoIndexManager.startScheduler(); auditLogPruner.startScheduler(); -if (env.PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('permission-syncing')) { +if (env.PERMISSION_SYNC_ENABLED === 'true' && !await hasEntitlement('permission-syncing')) { logger.error('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.'); process.exit(1); } -else if (env.PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) { +else if (env.PERMISSION_SYNC_ENABLED === 'true' && await hasEntitlement('permission-syncing')) { if (env.PERMISSION_SYNC_REPO_DRIVEN_ENABLED === 'true') { repoPermissionSyncer.startScheduler(); } diff --git a/packages/backend/src/prisma.ts b/packages/backend/src/prisma.ts new file mode 100644 index 000000000..325d50db7 --- /dev/null +++ b/packages/backend/src/prisma.ts @@ -0,0 +1,10 @@ +import { PrismaClient } from "@sourcebot/db"; +import { getDBConnectionString } from "@sourcebot/shared"; + +export const prisma = new PrismaClient({ + datasources: { + db: { + url: getDBConnectionString(), + }, + }, +}); diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index a69508515..d99727ea3 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -5,7 +5,7 @@ import { getTokenFromConfig } from "@sourcebot/shared"; import * as Sentry from "@sentry/node"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { GithubAppManager } from "./ee/githubAppManager.js"; -import { hasEntitlement } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; import { StatusCodes } from "http-status-codes"; import { isOctokitRequestError } from "./github.js"; @@ -116,7 +116,7 @@ export const fetchWithRetry = async ( // may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing. export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logger?: Logger): Promise => { // If we have github apps configured we assume that we must use them for github service auth - if (repo.external_codeHostType === 'github' && hasEntitlement('github-app') && GithubAppManager.getInstance().appsConfigured()) { + if (repo.external_codeHostType === 'github' && await hasEntitlement('github-app') && GithubAppManager.getInstance().appsConfigured()) { logger?.debug(`Using GitHub App for service auth for repo ${repo.displayName} hosted at ${repo.external_codeHostUrl}`); const owner = repo.displayName?.split('/')[0]; diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts index 5b2ab0d5d..6bdbe819b 100644 --- a/packages/backend/vitest.config.ts +++ b/packages/backend/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ test: { @@ -6,6 +7,9 @@ export default defineConfig({ watch: false, env: { DATA_CACHE_DIR: 'test-data' - } + }, + alias: { + './prisma.js': path.resolve(__dirname, 'src/__mocks__/prisma.ts'), + }, } }); \ No newline at end of file diff --git a/packages/db/prisma/migrations/20260417011834_add_license_table/migration.sql b/packages/db/prisma/migrations/20260417011834_add_license_table/migration.sql new file mode 100644 index 000000000..ff858260e --- /dev/null +++ b/packages/db/prisma/migrations/20260417011834_add_license_table/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "License" ( + "id" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + "activationCode" TEXT NOT NULL, + "entitlements" TEXT[], + "seats" INTEGER, + "status" TEXT, + "lastSyncAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "License_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "License_orgId_key" ON "License"("orgId"); + +-- AddForeignKey +ALTER TABLE "License" ADD CONSTRAINT "License_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260417224042_remove_guest_org_role/migration.sql b/packages/db/prisma/migrations/20260417224042_remove_guest_org_role/migration.sql new file mode 100644 index 000000000..036828359 --- /dev/null +++ b/packages/db/prisma/migrations/20260417224042_remove_guest_org_role/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - The values [GUEST] on the enum `OrgRole` will be removed. If these variants are still used in the database, this will fail. + +*/ + +-- Remove the guest user and its membership (only holder of GUEST role) +DELETE FROM "UserToOrg" WHERE "role" = 'GUEST'; +DELETE FROM "User" WHERE id = '1'; + +-- AlterEnum +BEGIN; +CREATE TYPE "OrgRole_new" AS ENUM ('OWNER', 'MEMBER'); +ALTER TABLE "UserToOrg" ALTER COLUMN "role" DROP DEFAULT; +ALTER TABLE "UserToOrg" ALTER COLUMN "role" TYPE "OrgRole_new" USING ("role"::text::"OrgRole_new"); +ALTER TYPE "OrgRole" RENAME TO "OrgRole_old"; +ALTER TYPE "OrgRole_new" RENAME TO "OrgRole"; +DROP TYPE "OrgRole_old"; +ALTER TABLE "UserToOrg" ALTER COLUMN "role" SET DEFAULT 'MEMBER'; +COMMIT; diff --git a/packages/db/prisma/migrations/20260418213423_add_billing_details_to_license/migration.sql b/packages/db/prisma/migrations/20260418213423_add_billing_details_to_license/migration.sql new file mode 100644 index 000000000..b73f7cf83 --- /dev/null +++ b/packages/db/prisma/migrations/20260418213423_add_billing_details_to_license/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "License" ADD COLUMN "currency" TEXT, +ADD COLUMN "interval" TEXT, +ADD COLUMN "intervalCount" INTEGER, +ADD COLUMN "nextRenewalAmount" INTEGER, +ADD COLUMN "nextRenewalAt" TIMESTAMP(3), +ADD COLUMN "planName" TEXT, +ADD COLUMN "unitAmount" INTEGER; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3a96eea6e..7cd4721cf 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -291,12 +291,33 @@ model Org { searchContexts SearchContext[] chats Chat[] + + license License? +} + +model License { + id String @id @default(cuid()) + orgId Int @unique + org Org @relation(fields: [orgId], references: [id]) + activationCode String + entitlements String[] + seats Int? + status String? /// See LicenseStatus in packages/shared/src/types.ts + planName String? + unitAmount Int? + currency String? + interval String? + intervalCount Int? + nextRenewalAt DateTime? + nextRenewalAmount Int? + lastSyncAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum OrgRole { OWNER MEMBER - GUEST } model UserToOrg { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 6dd5836fc..38a30bf59 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -12,8 +12,6 @@ export const API_KEY_PREFIX = 'sbk_'; export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_'; export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_'; -export const SOURCEBOT_UNLIMITED_SEATS = -1; - /** * Default settings. */ diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index ae2aa423c..fbb4be79b 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -27,6 +27,21 @@ export function encrypt(text: string): { iv: string; encryptedData: string } { return { iv: iv.toString('hex'), encryptedData: encrypted }; } +export function decrypt(iv: string, encryptedText: string): string { + const encryptionKey = Buffer.from(env.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; +} + + export function hashSecret(text: string): string { return crypto.createHmac('sha256', env.SOURCEBOT_ENCRYPTION_KEY).update(text).digest('hex'); } @@ -61,20 +76,6 @@ export function generateOAuthRefreshToken(): { token: string; hash: string } { }; } -export function decrypt(iv: string, encryptedText: string): string { - const encryptionKey = Buffer.from(env.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; -} - export function verifySignature(data: string, signature: string, publicKeyPath: string): boolean { try { let publicKey = publicKeyCache.get(publicKeyPath); @@ -226,3 +227,13 @@ export function decryptOAuthToken(encryptedText: string | null | undefined): str return encryptedText; } } + +export function encryptActivationCode(code: string): string { + const { iv, encryptedData } = encrypt(code); + return Buffer.from(JSON.stringify({ iv, encryptedData })).toString('base64'); +} + +export function decryptActivationCode(encrypted: string): string { + const { iv, encryptedData } = JSON.parse(Buffer.from(encrypted, 'base64').toString('utf8')); + return decrypt(iv, encryptedData); +} \ No newline at end of file diff --git a/packages/shared/src/entitlements.test.ts b/packages/shared/src/entitlements.test.ts new file mode 100644 index 000000000..4acdcbcce --- /dev/null +++ b/packages/shared/src/entitlements.test.ts @@ -0,0 +1,187 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { License } from '@sourcebot/db'; + +const mocks = vi.hoisted(() => ({ + env: { + SOURCEBOT_PUBLIC_KEY_PATH: '/tmp/test-key', + SOURCEBOT_EE_LICENSE_KEY: undefined as string | undefined, + } as Record, + verifySignature: vi.fn(() => true), +})); + +vi.mock('./env.server.js', () => ({ + env: mocks.env, +})); + +vi.mock('./crypto.js', () => ({ + verifySignature: mocks.verifySignature, +})); + +vi.mock('./logger.js', () => ({ + createLogger: () => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +import { + isAnonymousAccessAvailable, + getEntitlements, + hasEntitlement, +} from './entitlements.js'; + +const encodeOfflineKey = (payload: object): string => { + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `sourcebot_ee_${encoded}`; +}; + +const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString(); +const pastDate = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(); + +const validOfflineKey = (overrides: { seats?: number; expiryDate?: string } = {}) => + encodeOfflineKey({ + id: 'test-customer', + expiryDate: overrides.expiryDate ?? futureDate, + ...(overrides.seats !== undefined ? { seats: overrides.seats } : {}), + sig: 'fake-sig', + }); + +const makeLicense = (overrides: Partial = {}): License => ({ + id: 'lic_1', + orgId: 1, + activationCode: 'code', + entitlements: [], + seats: null, + status: null, + lastSyncAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +beforeEach(() => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = undefined; + mocks.verifySignature.mockReturnValue(true); +}); + +describe('isAnonymousAccessAvailable', () => { + describe('without any license', () => { + test('returns true when license is null', () => { + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('returns true when license has no status', () => { + expect(isAnonymousAccessAvailable(makeLicense())).toBe(true); + }); + + test('returns true when license status is canceled', () => { + expect(isAnonymousAccessAvailable(makeLicense({ status: 'canceled' }))).toBe(true); + }); + }); + + describe('with an active online license', () => { + test.each(['active', 'trialing', 'past_due'] as const)( + 'returns false when status is %s', + (status) => { + expect(isAnonymousAccessAvailable(makeLicense({ status }))).toBe(false); + } + ); + }); + + describe('with an offline license key', () => { + test('returns false when offline key has a seat count', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 }); + expect(isAnonymousAccessAvailable(null)).toBe(false); + }); + + test('returns true when offline key has no seat count (unlimited)', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('unlimited offline key beats an active online license', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(true); + }); + + test('falls through to online license check when offline key is expired', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100, expiryDate: pastDate }); + expect(isAnonymousAccessAvailable(null)).toBe(true); + expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(false); + }); + + test('falls through when offline key is malformed', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-valid-base64-or-json'; + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('falls through when offline key has wrong prefix', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'bogus_prefix_xyz'; + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('falls through when offline key signature is invalid', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 }); + mocks.verifySignature.mockReturnValue(false); + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + }); +}); + +describe('getEntitlements', () => { + test('returns empty array when no license and no offline key', () => { + expect(getEntitlements(null)).toEqual([]); + }); + + test('returns license.entitlements when license is active', () => { + const license = makeLicense({ status: 'active', entitlements: ['sso', 'audit'] }); + expect(getEntitlements(license)).toEqual(['sso', 'audit']); + }); + + test('returns empty when license has no status', () => { + expect(getEntitlements(makeLicense({ entitlements: ['sso'] }))).toEqual([]); + }); + + test('returns all entitlements when offline key is valid', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 }); + const result = getEntitlements(null); + expect(result).toContain('sso'); + expect(result).toContain('audit'); + expect(result).toContain('search-contexts'); + }); + + test('falls through when offline key is expired', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50, expiryDate: pastDate }); + expect(getEntitlements(null)).toEqual([]); + expect( + getEntitlements(makeLicense({ status: 'active', entitlements: ['sso'] })) + ).toEqual(['sso']); + }); + + test('falls through when offline key is malformed', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-a-valid-payload'; + expect(getEntitlements(null)).toEqual([]); + }); +}); + +describe('hasEntitlement', () => { + test('returns true when entitlement is present in license', () => { + expect( + hasEntitlement('sso', makeLicense({ status: 'active', entitlements: ['sso'] })) + ).toBe(true); + }); + + test('returns false when entitlement is absent from license', () => { + expect( + hasEntitlement('audit', makeLicense({ status: 'active', entitlements: ['sso'] })) + ).toBe(false); + }); + + test('returns true for any entitlement when offline key is valid', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 }); + expect(hasEntitlement('sso', null)).toBe(true); + expect(hasEntitlement('audit', null)).toBe(true); + }); +}); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index de841a0dd..8dce48255 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -2,36 +2,32 @@ import { base64Decode } from "./utils.js"; import { z } from "zod"; import { createLogger } from "./logger.js"; import { env } from "./env.server.js"; -import { SOURCEBOT_SUPPORT_EMAIL, SOURCEBOT_UNLIMITED_SEATS } from "./constants.js"; import { verifySignature } from "./crypto.js"; +import { License } from "@sourcebot/db"; +import { LicenseStatus } from "./types.js"; const logger = createLogger('entitlements'); -const eeLicenseKeyPrefix = "sourcebot_ee_"; - -const eeLicenseKeyPayloadSchema = z.object({ +const offlineLicensePrefix = "sourcebot_ee_"; +const offlineLicensePayloadSchema = z.object({ id: z.string(), - seats: z.number(), + seats: z.number().optional(), // ISO 8601 date string expiryDate: z.string().datetime(), sig: z.string(), }); -type LicenseKeyPayload = z.infer; +type getValidOfflineLicense = z.infer; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const planLabels = { - oss: "OSS", - "self-hosted:enterprise": "Enterprise (Self-Hosted)", - "self-hosted:enterprise-unlimited": "Enterprise (Self-Hosted) Unlimited", -} as const; -export type Plan = keyof typeof planLabels; +const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [ + 'active', + 'trialing', + 'past_due', +]; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const entitlements = [ +const ALL_ENTITLEMENTS = [ "search-contexts", - "anonymous-access", - "multi-tenancy", "sso", "code-nav", "audit", @@ -42,100 +38,102 @@ const entitlements = [ "org-management", "oauth", ] as const; -export type Entitlement = (typeof entitlements)[number]; - -const entitlementsByPlan: Record = { - oss: [ - "anonymous-access", - ], - "self-hosted:enterprise": [ - "search-contexts", - "sso", - "code-nav", - "audit", - "analytics", - "permission-syncing", - "github-app", - "chat-sharing", - "org-management", - "oauth", - ], - "self-hosted:enterprise-unlimited": [ - "anonymous-access", - "search-contexts", - "sso", - "code-nav", - "audit", - "analytics", - "permission-syncing", - "github-app", - "chat-sharing", - "org-management", - "oauth", - ], -} as const; - - -const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => { +export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; + +const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense | null => { try { const decodedPayload = base64Decode(payload); const payloadJson = JSON.parse(decodedPayload); - const licenseData = eeLicenseKeyPayloadSchema.parse(payloadJson); - + const licenseData = offlineLicensePayloadSchema.parse(payloadJson); + const dataToVerify = JSON.stringify({ expiryDate: licenseData.expiryDate, id: licenseData.id, seats: licenseData.seats }); - + const isSignatureValid = verifySignature(dataToVerify, licenseData.sig, env.SOURCEBOT_PUBLIC_KEY_PATH); if (!isSignatureValid) { logger.error('License key signature verification failed'); - process.exit(1); + return null; } - + return licenseData; } catch (error) { logger.error(`Failed to decode license key payload: ${error}`); - process.exit(1); + return null; } } -export const getLicenseKey = (): LicenseKeyPayload | null => { +const getValidOfflineLicense = (): getValidOfflineLicense | null => { const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY; - if (licenseKey && licenseKey.startsWith(eeLicenseKeyPrefix)) { - const payload = licenseKey.substring(eeLicenseKeyPrefix.length); - return decodeLicenseKeyPayload(payload); + if (!licenseKey || !licenseKey.startsWith(offlineLicensePrefix)) { + return null; + } + + const payload = decodeOfflineLicenseKeyPayload(licenseKey.substring(offlineLicensePrefix.length)); + if (!payload) { + return null; + } + + const expiryDate = new Date(payload.expiryDate); + if (expiryDate.getTime() < new Date().getTime()) { + return null; + } + + return payload; +} + +const getValidOnlineLicense = (_license: License | null): License | null => { + if ( + _license && + _license.status && + ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as LicenseStatus) + ) { + return _license; } + return null; } -export const getPlan = (): Plan => { - const licenseKey = getLicenseKey(); - if (licenseKey) { - const expiryDate = new Date(licenseKey.expiryDate); - if (expiryDate.getTime() < new Date().getTime()) { - logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); - process.exit(1); - } +export const isAnonymousAccessAvailable = (_license: License | null): boolean => { + const offlineKey = getValidOfflineLicense(); + if (offlineKey) { + return offlineKey.seats === undefined; + } - return licenseKey.seats === SOURCEBOT_UNLIMITED_SEATS ? "self-hosted:enterprise-unlimited" : "self-hosted:enterprise"; - } else { - return "oss"; + const onlineLicense = getValidOnlineLicense(_license); + if (onlineLicense) { + return false; } + return true; } -export const getSeats = (): number => { -const licenseKey = getLicenseKey(); - return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS; +export const getEntitlements = (_license: License | null): Entitlement[] => { + const offlineLicense = getValidOfflineLicense(); + if (offlineLicense) { + return ALL_ENTITLEMENTS as unknown as Entitlement[]; + } + + const onlineLicense = getValidOnlineLicense(_license); + if (onlineLicense) { + return onlineLicense.entitlements as unknown as Entitlement[]; + } + else { + return []; + } } -export const hasEntitlement = (entitlement: Entitlement) => { - const entitlements = getEntitlements(); +export const hasEntitlement = (entitlement: Entitlement, _license: License | null) => { + const entitlements = getEntitlements(_license); return entitlements.includes(entitlement); } -export const getEntitlements = (): Entitlement[] => { - const plan = getPlan(); - return entitlementsByPlan[plan]; -} +export const getSeatCap = (): number | undefined => { + const offlineLicense = getValidOfflineLicense(); + if (offlineLicense?.seats && offlineLicense.seats > 0) { + return offlineLicense.seats; + } + + return undefined; +} \ No newline at end of file diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 22bf2242e..cc51d768c 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -269,6 +269,7 @@ const options = { SOURCEBOT_ENCRYPTION_KEY: z.string(), SOURCEBOT_INSTALL_ID: z.string().default("unknown"), + SOURCEBOT_LIGHTHOUSE_URL: z.string().url(), FALLBACK_GITHUB_CLOUD_TOKEN: z.string().optional(), FALLBACK_GITLAB_CLOUD_TOKEN: z.string().optional(), diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index a1eb34204..7414631a5 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -1,18 +1,20 @@ +// types prefixed with _ are intended to be wrapped +// by the consumer. See web/entitlements.ts and +// backend/entitlements.ts export { - hasEntitlement, - getLicenseKey, - getPlan, - getSeats, - getEntitlements, + hasEntitlement as _hasEntitlement, + getEntitlements as _getEntitlements, + isAnonymousAccessAvailable as _isAnonymousAccessAvailable, + getSeatCap, } from "./entitlements.js"; export type { - Plan, Entitlement, } from "./entitlements.js"; export type { RepoMetadata, RepoIndexingJobMetadata, IdentityProviderType, + LicenseStatus, } from "./types.js"; export { repoMetadataSchema, @@ -49,6 +51,8 @@ export { verifySignature, encryptOAuthToken, decryptOAuthToken, + encryptActivationCode, + decryptActivationCode, } from "./crypto.js"; export { getDBConnectionString, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index b0291a57b..3045986b2 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -64,4 +64,15 @@ export const repoIndexingJobMetadataSchema = z.object({ export type RepoIndexingJobMetadata = z.infer; -export type IdentityProviderType = IdentityProviderConfig['provider']; \ No newline at end of file +export type IdentityProviderType = IdentityProviderConfig['provider']; + +// @see: https://docs.stripe.com/api/subscriptions/object#subscription_object-status +export type LicenseStatus = + 'active' | + 'trialing' | + 'past_due' | + 'unpaid' | + 'canceled' | + 'incomplete' | + 'incomplete_expired' | + 'paused'; diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index 148b3a18b..74728183f 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ NODE_ENV: 'test', CONFIG_PATH: '/tmp/test-config.json', SOURCEBOT_ENCRYPTION_KEY: 'test-encryption-key-32-characters!', + SOURCEBOT_LIGHTHOUSE_URL: 'http://localhost:3003', } } }); diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 939ba442c..1a3396c26 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,11 +1,10 @@ 'use server'; -import { getAuditService } from "@/ee/features/audit/factory"; +import { createAudit } from "@/ee/features/audit/audit"; import { env, getSMTPConnectionURL } from "@sourcebot/shared"; -import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, orgNotFound, ServiceError } from "@/lib/serviceError"; -import { getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils"; +import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError"; +import { getOrgMetadata, isHttpError } from "@/lib/utils"; import { __unsafePrisma } from "@/prisma"; import { render } from "@react-email/components"; import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; @@ -14,13 +13,11 @@ import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; -import { getPlan, hasEntitlement } from "@sourcebot/shared"; +import { isAnonymousAccessAvailable } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; import { createTransport } from "nodemailer"; import { Octokit } from "octokit"; -import InviteUserEmail from "./emails/inviteUserEmail"; -import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { RepositoryQuery } from "./lib/types"; @@ -30,7 +27,6 @@ import { getBrowsePath } from "./app/(app)/browse/hooks/utils"; import { sew } from "@/middleware/sew"; const logger = createLogger('web-actions'); -const auditService = getAuditService(); ////// Actions /////// export const completeOnboarding = async (): Promise<{ success: boolean } | ServiceError> => sew(() => @@ -66,7 +62,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv }); if (existingApiKey) { - await auditService.createAudit({ + await createAudit({ action: "api_key.creation_failed", actor: { id: user.id, @@ -99,7 +95,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv } }); - await auditService.createAudit({ + await createAudit({ action: "api_key.created", actor: { id: user.id, @@ -127,7 +123,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | }); if (!apiKey) { - await auditService.createAudit({ + await createAudit({ action: "api_key.deletion_failed", actor: { id: user.id, @@ -156,7 +152,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | }, }); - await auditService.createAudit({ + await createAudit({ action: "api_key.deleted", actor: { id: user.id, @@ -519,304 +515,6 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin } })); -export const getCurrentUserRole = async (): Promise => sew(() => - withOptionalAuth(async ({ role }) => { - return role; - })); - -export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ org, user, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const failAuditCallback = async (error: string) => { - await auditService.createAudit({ - action: "user.invite_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: error, - emails: emails.join(", ") - } - }); - } - - const hasAvailability = await orgHasAvailability(); - if (!hasAvailability) { - await auditService.createAudit({ - action: "user.invite_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: "Organization has reached maximum number of seats", - emails: emails.join(", ") - } - }); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, - message: "The organization has reached the maximum number of seats. Unable to create a new invite", - } satisfies ServiceError; - } - - // Check for existing invites - const existingInvites = await prisma.invite.findMany({ - where: { - recipientEmail: { - in: emails - }, - orgId: org.id, - } - }); - - if (existingInvites.length > 0) { - await failAuditCallback("A pending invite already exists for one or more of the provided emails"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_INVITE, - message: `A pending invite already exists for one or more of the provided emails.`, - } satisfies ServiceError; - } - - // Check for members that are already in the org - const existingMembers = await prisma.userToOrg.findMany({ - where: { - user: { - email: { - in: emails, - } - }, - orgId: org.id, - }, - }); - - if (existingMembers.length > 0) { - await failAuditCallback("One or more of the provided emails are already members of this org"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_INVITE, - message: `One or more of the provided emails are already members of this org.`, - } satisfies ServiceError; - } - - await prisma.invite.createMany({ - data: emails.map((email) => ({ - recipientEmail: email, - hostUserId: user.id, - orgId: org.id, - })), - skipDuplicates: true, - }); - - // Send invites to recipients - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - await Promise.all(emails.map(async (email) => { - const invite = await prisma.invite.findUnique({ - where: { - recipientEmail_orgId: { - recipientEmail: email, - orgId: org.id, - }, - }, - include: { - org: true, - } - }); - - if (!invite) { - return; - } - - const recipient = await prisma.user.findUnique({ - where: { - email, - }, - }); - const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`; - const transport = createTransport(smtpConnectionUrl); - const html = await render(InviteUserEmail({ - baseUrl: env.AUTH_URL, - host: { - name: user.name ?? undefined, - email: user.email!, - avatarUrl: user.image ?? undefined, - }, - recipient: { - name: recipient?.name ?? undefined, - }, - orgName: invite.org.name, - orgImageUrl: invite.org.imageUrl ?? undefined, - inviteLink, - })); - - const result = await transport.sendMail({ - to: email, - from: env.EMAIL_FROM_ADDRESS, - subject: `Join ${invite.org.name} on Sourcebot`, - html, - text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send invite email to ${email}: ${failed}`); - } - })); - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`); - } - - await auditService.createAudit({ - action: "user.invites_created", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - emails: emails.join(", ") - } - }); - return { - success: true, - } - }) - )); - -export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - orgId: org.id, - }, - }); - - if (!invite) { - return notFound(); - } - - await prisma.invite.delete({ - where: { - id: inviteId, - }, - }); - - return { - success: true, - } - }) - )); - -export const getMe = async () => sew(() => - withAuth(async ({ user, prisma }) => { - const userWithOrgs = await prisma.user.findUnique({ - where: { - id: user.id, - }, - include: { - orgs: { - include: { - org: true, - } - }, - } - }); - - if (!userWithOrgs) { - return notFound(); - } - - return { - id: userWithOrgs.id, - email: userWithOrgs.email, - name: userWithOrgs.name, - image: userWithOrgs.image, - memberships: userWithOrgs.orgs.map((org) => ({ - id: org.orgId, - role: org.role, - name: org.org.name, - })) - } - })); - -export const getOrgMembers = async () => sew(() => - withAuth(async ({ org, prisma }) => { - const members = await prisma.userToOrg.findMany({ - where: { - orgId: org.id, - role: { - not: OrgRole.GUEST, - } - }, - include: { - user: true, - }, - }); - - return members.map((member) => ({ - id: member.userId, - email: member.user.email!, - name: member.user.name ?? undefined, - avatarUrl: member.user.image ?? undefined, - role: member.role, - joinedAt: member.joinedAt, - })); - })); - -export const getOrgInvites = async () => sew(() => - withAuth(async ({ org, prisma }) => { - const invites = await prisma.invite.findMany({ - where: { - orgId: org.id, - }, - }); - - return invites.map((invite) => ({ - id: invite.id, - email: invite.recipientEmail, - createdAt: invite.createdAt, - })); - })); - -export const getOrgAccountRequests = async () => sew(() => - withAuth(async ({ org, prisma }) => { - const requests = await prisma.accountRequest.findMany({ - where: { - orgId: org.id, - }, - include: { - requestedBy: true, - }, - }); - - return requests.map((request) => ({ - id: request.id, - email: request.requestedBy.email!, - createdAt: request.createdAt, - name: request.requestedBy.name ?? undefined, - image: request.requestedBy.image ?? undefined, - })); - })); - export const createAccountRequest = async () => sew(async () => { const authResult = await getAuthenticatedUser(); if (!authResult) { @@ -920,20 +618,6 @@ export const createAccountRequest = async () => sew(async () => { } }); -export const getMemberApprovalRequired = async (): Promise => sew(async () => { - const org = await __unsafePrisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - }); - - if (!org) { - return orgNotFound(); - } - - return org.memberApprovalRequired; -}); - export const setMemberApprovalRequired = async (required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => withAuth(async ({ org, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { @@ -964,121 +648,6 @@ export const setInviteLinkEnabled = async (enabled: boolean): Promise<{ success: ) ); -export const approveAccountRequest = async (requestId: string) => sew(async () => - withAuth(async ({ org, user, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const failAuditCallback = async (error: string) => { - await auditService.createAudit({ - action: "user.join_request_approve_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: requestId, - type: "account_join_request" - }, - orgId: org.id, - metadata: { - message: error, - } - }); - } - - const request = await prisma.accountRequest.findUnique({ - where: { - id: requestId, - }, - include: { - requestedBy: true, - }, - }); - - if (!request || request.orgId !== org.id) { - await failAuditCallback("Request not found"); - return notFound(); - } - - const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id); - if (isServiceError(addUserToOrgRes)) { - await failAuditCallback(addUserToOrgRes.message); - return addUserToOrgRes; - } - - // Send approval email to the user - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - const html = await render(JoinRequestApprovedEmail({ - baseUrl: env.AUTH_URL, - user: { - name: request.requestedBy.name ?? undefined, - email: request.requestedBy.email!, - avatarUrl: request.requestedBy.image ?? undefined, - }, - orgName: org.name, - })); - - const transport = createTransport(smtpConnectionUrl); - const result = await transport.sendMail({ - to: request.requestedBy.email!, - from: env.EMAIL_FROM_ADDRESS, - subject: `Your request to join ${org.name} has been approved`, - html, - text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); - } - - await auditService.createAudit({ - action: "user.join_request_approved", - actor: { - id: user.id, - type: "user" - }, - orgId: org.id, - target: { - id: requestId, - type: "account_join_request" - } - }); - return { - success: true, - } - }) - )); - -export const rejectAccountRequest = async (requestId: string) => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const request = await prisma.accountRequest.findUnique({ - where: { - id: requestId, - }, - }); - - if (!request || request.orgId !== org.id) { - return notFound(); - } - - await prisma.accountRequest.delete({ - where: { - id: requestId, - }, - }); - - return { - success: true, - } - }) - )); - - export const getSearchContexts = async () => sew(() => withOptionalAuth(async ({ org, prisma }) => { const searchContexts = await prisma.searchContext.findMany({ @@ -1167,42 +736,12 @@ export const getRepoImage = async (repoId: number): Promise => sew(async () => { - const org = await __unsafePrisma.org.findUnique({ - where: { id: SINGLE_TENANT_ORG_ID }, - }); - if (!org) { - return { - statusCode: StatusCodes.NOT_FOUND, - errorCode: ErrorCode.NOT_FOUND, - message: "Organization not found", - } satisfies ServiceError; - } - - // If no metadata is set we don't try to parse it since it'll result in a parse error - if (org.metadata === null) { - return false; - } - - const orgMetadata = getOrgMetadata(org); - if (!orgMetadata) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.INVALID_ORG_METADATA, - message: "Invalid organization metadata", - } satisfies ServiceError; - } - - return !!orgMetadata.anonymousAccessEnabled; -}); - export const setAnonymousAccessStatus = async (enabled: boolean): Promise => sew(async () => { return await withAuth(async ({ org, role, prisma }) => { return await withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); - if (!hasAnonymousAccessEntitlement) { - const plan = getPlan(); - console.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); + const anonymousAccessAvailable = await isAnonymousAccessAvailable(); + if (!anonymousAccessAvailable) { + console.error(`Anonymous access isn't supported in your current plan. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); return { statusCode: StatusCodes.FORBIDDEN, errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx index d222518a7..c215af57c 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -2,7 +2,8 @@ import { cookies } from "next/headers"; import { auth } from "@/auth"; import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants"; import { HomeView } from "@/hooks/useHomeView"; -import { getConnectionStats, getOrgAccountRequests } from "@/actions"; +import { getConnectionStats } from "@/actions"; +import { getOrgAccountRequests } from "@/features/userManagement/actions"; import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; import { __unsafePrisma } from "@/prisma"; diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/header.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/header.tsx new file mode 100644 index 000000000..ab2d6b3ab --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/header.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; + +export function SettingsSidebarHeader() { + return ( + + + + + + Back to app + + + + + ); +} diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx index 85e3d0ae2..fc30ad7f4 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx @@ -4,13 +4,7 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { getSidebarNavGroups } from "@/app/(app)/settings/layout"; import { SidebarBase } from "../sidebarBase"; import { Nav } from "./nav"; -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar"; -import { ArrowLeftIcon } from "lucide-react"; -import Link from "next/link"; +import { SettingsSidebarHeader } from "./header"; export async function SettingsSidebar() { const session = await auth(); @@ -24,18 +18,7 @@ export async function SettingsSidebar() { - - - - - Back to app - - - - - } + headerContent={} >