diff --git a/.cursor/rules/style.mdc b/.cursor/rules/style.mdc new file mode 100644 index 00000000..6d3e8046 --- /dev/null +++ b/.cursor/rules/style.mdc @@ -0,0 +1,7 @@ +--- +description: +globs: +alwaysApply: true +--- +- Always use 4 spaces for indentation +- Filenames should always be camelCase. Exception: if there are filenames in the same directory with a format other than camelCase, use that format to keep things consistent. \ No newline at end of file diff --git a/.env.development b/.env.development index f4ca1600..1c90cd01 100644 --- a/.env.development +++ b/.env.development @@ -18,10 +18,10 @@ SRC_TENANT_ENFORCEMENT_MODE=strict AUTH_SECRET="00000000000000000000000000000000000000000000" AUTH_URL="http://localhost:3000" # AUTH_CREDENTIALS_LOGIN_ENABLED=true -# AUTH_GITHUB_CLIENT_ID="" -# AUTH_GITHUB_CLIENT_SECRET="" -# AUTH_GOOGLE_CLIENT_ID="" -# AUTH_GOOGLE_CLIENT_SECRET="" +# AUTH_EE_GITHUB_CLIENT_ID="" +# AUTH_EE_GITHUB_CLIENT_SECRET="" +# AUTH_EE_GOOGLE_CLIENT_ID="" +# AUTH_EE_GOOGLE_CLIENT_SECRET="" DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot) # CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b8b551b..2dbea0cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Sourcebot V4 introduces authentication, performance improvements and code navigation. Checkout the [migration guide](https://docs.sourcebot.dev/self-hosting/upgrade/v3-to-v4-guide) for information on upgrading your instance to v4. + +### Changed +- [**Breaking Change**] Authentication is now required by default. Notes: + - When setting up your instance, email / password login will be the default authentication provider. + - The first user that logs into the instance is given the `owner` role. ([docs](https://docs.sourcebot.dev/docs/more/roles-and-permissions)). + - Subsequent users can request to join the instance. The `owner` can approve / deny requests to join the instance via `Settings` > `Members` > `Pending Requests`. + - If a user is approved to join the instance, they are given the `member` role. + - Additional login providers, including email links and SSO, can be configured with additional environment variables. ([docs](https://docs.sourcebot.dev/self-hosting/configuration/authentication)). +- Clicking on a search result now takes you to the `/browse` view. Files can still be previewed by clicking the "Preview" button or holding `Cmd` / `Ctrl` when clicking on a search result. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) + +### Added +- [Sourcebot EE] Added search-based code navigation, allowing you to jump between symbol definition and references when viewing source files. [Read the documentation](https://docs.sourcebot.dev/docs/search/code-navigation). [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) +- Added collapsible filter panel. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) +- Added Sourcebot API key management for external clients. [#311](https://github.com/sourcebot-dev/sourcebot/pull/311) + +### Fixed +- Improved scroll performance for large numbers of search results. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) + ## [3.2.1] - 2025-05-15 ### Added @@ -93,8 +112,8 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou ### Added - Added parallelized repo indexing and connection syncing via Redis & BullMQ. See the [architecture overview](https://docs.sourcebot.dev/self-hosting/overview#architecture). - Added repo indexing progress indicators in the navbar. -- Added authentication support via OAuth or email/password. For instructions on enabling, see [this doc](https://docs.sourcebot.dev/self-hosting/more/authentication). -- Added the following UI for managing your deployment when **[auth is enabled](https://docs.sourcebot.dev/self-hosting/more/authentication)**: +- Added authentication support via OAuth or email/password. For instructions on enabling, see [this doc](https://docs.sourcebot.dev/self-hosting/configuration/authentication). +- Added the following UI for managing your deployment when **[auth is enabled](https://docs.sourcebot.dev/self-hosting/configuration/authentication)**: - connection management: create and manage your JSON configs via a integrated web-editor. - secrets: import personal access tokens (PAT) into Sourcebot (AES-256 encrypted). Reference secrets in your connection config by name. - team & invite management: invite users to your instance to give them access. Configure team [roles & permissions](https://docs.sourcebot.dev/docs/more/roles-and-permissions). diff --git a/docs/docs.json b/docs/docs.json index a3bac5cc..607deb9d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -50,6 +50,7 @@ "pages": [ "docs/search/syntax-reference", "docs/search/multi-branch-indexing", + "docs/search/code-navigation", "docs/search/search-contexts" ] }, @@ -63,6 +64,7 @@ { "group": "More", "pages": [ + "docs/more/api-keys", "docs/more/roles-and-permissions", "docs/more/mcp-server" ] @@ -77,17 +79,16 @@ "group": "Getting Started", "pages": [ "self-hosting/overview", - "self-hosting/configuration", "self-hosting/license-key" ] }, { - "group": "More", + "group": "Configuration", "pages": [ - "self-hosting/more/authentication", - "self-hosting/more/tenancy", - "self-hosting/more/transactional-emails", - "self-hosting/more/declarative-config" + "self-hosting/configuration/environment-variables", + "self-hosting/configuration/authentication", + "self-hosting/configuration/transactional-emails", + "self-hosting/configuration/declarative-config" ] }, { @@ -98,6 +99,7 @@ { "group": "Upgrade", "pages": [ + "self-hosting/upgrade/v3-to-v4-guide", "self-hosting/upgrade/v2-to-v3-guide" ] } diff --git a/docs/docs/agents/review-agent.mdx b/docs/docs/agents/review-agent.mdx index 27d9932d..1d81d58f 100644 --- a/docs/docs/agents/review-agent.mdx +++ b/docs/docs/agents/review-agent.mdx @@ -53,6 +53,7 @@ Before you get started, make sure you have an OpenAPI account that you can creat directory that you mount to Sourcebot ![GitHub App Private Key](/images/github_app_private_key.png) - `OPENAI_API_KEY`: Your OpenAI API key + - `REVIEW_AGENT_API_KEY`: The Sourcebot API key that the review agent uses to hit the Sourcebot API to fetch code context - `REVIEW_AGENT_AUTO_REVIEW_ENABLED` (default: `false`): If enabled, the review agent will automatically review any new or updated PR. If disabled, you must invoke it using the command defined by `REVIEW_AGENT_REVIEW_COMMAND` - `REVIEW_AGENT_REVIEW_COMMAND` (default: `review`): The command that invokes the review agent (ex. `/review`) when a user comments on the PR. Don't include the slash character in this value. @@ -76,6 +77,7 @@ Before you get started, make sure you have an OpenAPI account that you can creat GITHUB_APP_ID: "my-github-app-id" GITHUB_APP_WEBHOOK_SECRET: "my-github-app-webhook-secret" GITHUB_APP_PRIVATE_KEY_PATH: "/data/review-agent-key.pem" + REVIEW_AGENT_API_KEY: "sourcebot-my-key" OPENAI_API_KEY: "sk-proj-my-open-api-key" ``` diff --git a/docs/docs/connections/gitea.mdx b/docs/docs/connections/gitea.mdx index 7e670d2d..810085a2 100644 --- a/docs/docs/connections/gitea.mdx +++ b/docs/docs/connections/gitea.mdx @@ -82,7 +82,7 @@ Next, provide the access token via the `token` property, either as an environmen - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -107,7 +107,7 @@ Next, provide the access token via the `token` property, either as an environmen - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: diff --git a/docs/docs/connections/github.mdx b/docs/docs/connections/github.mdx index 58a1d59f..52bb2f3e 100644 --- a/docs/docs/connections/github.mdx +++ b/docs/docs/connections/github.mdx @@ -111,7 +111,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -136,7 +136,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: diff --git a/docs/docs/connections/gitlab.mdx b/docs/docs/connections/gitlab.mdx index 87f01661..4740bcc0 100644 --- a/docs/docs/connections/gitlab.mdx +++ b/docs/docs/connections/gitlab.mdx @@ -116,7 +116,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -141,7 +141,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: diff --git a/docs/docs/connections/overview.mdx b/docs/docs/connections/overview.mdx index a237aa67..65bac506 100644 --- a/docs/docs/connections/overview.mdx +++ b/docs/docs/connections/overview.mdx @@ -11,11 +11,11 @@ There are two ways to define connections: - This is only supported when self-hosting, and is the default mechanism to define connections. Connections are defined in a [JSON file](/self-hosting/more/declarative-config) + This is only supported when self-hosting, and is the default mechanism to define connections. Connections are defined in a [JSON file](/self-hosting/configuration/declarative-config) and the path to the file is provided through the `CONFIG_PATH` environment variable - This is the only way to define connections when using Sourcebot Cloud, and can be configured when self-hosting by enabling [authentication](/self-hosting/more/authentications). + This is the only way to define connections when using Sourcebot Cloud, and can be configured when self-hosting by enabling [authentication](/self-hosting/configuration/authentications). In this method, connections are defined and managed within the webapp: diff --git a/docs/docs/more/api-keys.mdx b/docs/docs/more/api-keys.mdx new file mode 100644 index 00000000..4aa31a69 --- /dev/null +++ b/docs/docs/more/api-keys.mdx @@ -0,0 +1,8 @@ +--- +title: API Keys +--- + +An API Key is required when querying Sourcebot outside the context of the web app client (ex. MCP server, review agent). To create an API key, login to your Sourcebot instance and navigate to +**Settings -> API Keys**: + +![API Keys UI](/images/api_key.png) \ No newline at end of file diff --git a/docs/docs/more/mcp-server.mdx b/docs/docs/more/mcp-server.mdx index 8521e06d..378e8280 100644 --- a/docs/docs/more/mcp-server.mdx +++ b/docs/docs/more/mcp-server.mdx @@ -4,7 +4,7 @@ sidebarTitle: Sourcebot MCP server --- -This feature is only available when [self-hosting](/self-hosting) with [authentication](/self-hosting/more/authentication) disabled. +This feature is only available when [self-hosting](/self-hosting) The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is an open standard for providing context to LLMs. The [@sourcebot/mcp](https://www.npmjs.com/package/@sourcebot/mcp) package is a MCP server that enables LLMs to interface with your Sourcebot instance, enabling MCP clients like Cursor, Vscode, and others to have context over your entire codebase. @@ -176,6 +176,7 @@ Parameters: | Name | Default | Description | |:-------------------------|:-----------------------|:--------------------------------------------------| | `SOURCEBOT_HOST` | http://localhost:3000 | URL of your Sourcebot instance. | +| `SOURCEBOT_API_KEY` | - | Sourcebot API key. | | `DEFAULT_MINIMUM_TOKENS` | 10000 | Minimum number of tokens to return in responses. | | `DEFAULT_MATCHES` | 10000 | Number of code matches to fetch per search. | | `DEFAULT_CONTEXT_LINES` | 5 | Lines of context to include above/below matches. | diff --git a/docs/docs/more/roles-and-permissions.mdx b/docs/docs/more/roles-and-permissions.mdx index 92ff91a7..43be9319 100644 --- a/docs/docs/more/roles-and-permissions.mdx +++ b/docs/docs/more/roles-and-permissions.mdx @@ -4,8 +4,7 @@ title: Roles and Permissions Looking to sync permissions with your identify provider? We're working on it - [reach out](https://www.sourcebot.dev/contact) to us to learn more -If you're using Sourcebot Cloud, or are self-hosting with [authentication](/self-hosting/more/authentication) enabled, you may have multiple members in your organization. Each -member has a role which defines their permissions: +Each member has a role which defines their permissions within an organization: | Role | Permission | | :--- | :--------- | diff --git a/docs/docs/search/code-navigation.mdx b/docs/docs/search/code-navigation.mdx new file mode 100644 index 00000000..daced8ba --- /dev/null +++ b/docs/docs/search/code-navigation.mdx @@ -0,0 +1,44 @@ +--- +title: Code navigation +sidebarTitle: Code navigation +--- + +import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx' + + +This feature is only available in [Sourcebot cloud](app.sourcebot.dev) or with an active Enterprise license when [self-hosting](/self-hosting). Please add your [license key](/self-hosting/license-key) to activate it. + + +**Code navigation** allows you to jump between symbol definition and references when viewing source files in Sourcebot. This feature is enabled **automatically** when a valid license key is present and works with all popular programming languages. + + + + +## Features + +| Feature | Description | +|:--------|:------------| +| **Hover popover** | Hovering over a symbol reveals the symbol's definition signature as a inline preview. | +| **Go to definition** | Clicking the "go to definition" button in the popover or clicking the symbol name navigates to the symbol's definition. | +| **Find references** | Clicking the "find all references" button in the popover lists all references in the explore panel. | +| **Explore panel** | Lists all references and definitions for the symbol selected in the popover. | + +## How does it work? + +Code navigation is **search-based**, meaning it uses the same code search engine and [query language](/docs/search/syntax-reference) to estimate a symbol's references and definitions. We refer to these estimations as "search heuristics". We have two search heuristics to enable the following operations: + +### Find references +Given a `symbolName`, along with information about the file the symbol is contained within (`git_revision`, and `language`), runs the following search: + +```bash +\\b{symbolName}\\b rev:{git_revision} lang:{language} case:yes +``` + +### Find definitions +Given a `symbolName`, along with information about the file the symbol is contained within (`git_revision`, and `language`), runs the following search: + +```bash +sym:\\b{symbolName}\\b rev:{git_revision} lang:{language} +``` + +Note that the `sym:` prefix is used to filter the search by symbol definitions. These are created at index time by [universal ctags](https://ctags.io/). diff --git a/docs/docs/search/search-contexts.mdx b/docs/docs/search/search-contexts.mdx index d701cc60..e6afca59 100644 --- a/docs/docs/search/search-contexts.mdx +++ b/docs/docs/search/search-contexts.mdx @@ -1,6 +1,6 @@ --- title: Search contexts -sidebarTitle: Search contexts (EE) +sidebarTitle: Search contexts --- import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx' @@ -16,7 +16,7 @@ A **search context** is a user-defined grouping of repositories that helps focus - `( context:project1 or context:project2 ) logger\.debug` - search for debug log calls in project1 and project2 -Search contexts are defined in the `context` object inside of a [declarative config](/self-hosting/more/declarative-config). Repositories can be included / excluded from a search context by specifying the repo's URL in either the `include` array or `exclude` array. Glob patterns are supported. +Search contexts are defined in the `context` object inside of a [declarative config](/self-hosting/configuration/declarative-config). Repositories can be included / excluded from a search context by specifying the repo's URL in either the `include` array or `exclude` array. Glob patterns are supported. ## Example @@ -41,7 +41,7 @@ shared/ ├─ ... ``` -To make searching easier, we can create three search contexts in our [config.json](/self-hosting/more/declarative-config): +To make searching easier, we can create three search contexts in our [config.json](/self-hosting/configuration/declarative-config): - `web`: For all frontend-related code - `backend`: For backend services and shared APIs - `pipelines`: For all CI/CD configurations diff --git a/docs/images/api_key.png b/docs/images/api_key.png new file mode 100644 index 00000000..64e94e6e Binary files /dev/null and b/docs/images/api_key.png differ diff --git a/docs/images/demo.mp4 b/docs/images/demo.mp4 deleted file mode 100644 index e6162d19..00000000 Binary files a/docs/images/demo.mp4 and /dev/null differ diff --git a/docs/images/join_request_email.png b/docs/images/join_request_email.png new file mode 100644 index 00000000..3c076c5d Binary files /dev/null and b/docs/images/join_request_email.png differ diff --git a/docs/images/login.png b/docs/images/login.png index 08d2d591..93ac56d0 100644 Binary files a/docs/images/login.png and b/docs/images/login.png differ diff --git a/docs/images/login_basic.png b/docs/images/login_basic.png new file mode 100644 index 00000000..0aff946b Binary files /dev/null and b/docs/images/login_basic.png differ diff --git a/docs/images/pending_approval.png b/docs/images/pending_approval.png new file mode 100644 index 00000000..b242a570 Binary files /dev/null and b/docs/images/pending_approval.png differ diff --git a/docs/self-hosting/configuration.mdx b/docs/self-hosting/configuration.mdx deleted file mode 100644 index bf740dcc..00000000 --- a/docs/self-hosting/configuration.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Configuration -sidebarTitle: Configuration ---- - - -## Environment Variables - -Sourcebot accepts a variety of environment variables to fine tune your deployment. - -| Variable | Default | Description | -| :------- | :------ | :---------- | -| `SOURCEBOT_LOG_LEVEL` | `info` |

The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.

| -| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` |

Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.

If you'd like to use a non-default schema, you can provide it as a parameter in the database url

| -| `REDIS_URL` | `redis://localhost:6379` |

Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.

| -| `SOURCEBOT_ENCRYPTION_KEY` | - |

Used to encrypt connection secrets. Generated using `openssl rand -base64 24`. Automatically generated at startup if no value is provided.

| -| `AUTH_SECRET` | - |

Used to validate login session cookies. Generated using `openssl rand -base64 33`. Automatically generated at startup if no value is provided.

| -| `AUTH_URL` | - |

URL of your Sourcebot deployment, e.g., `https://example.com` or `http://localhost:3000`. Required when `SOURCEBOT_AUTH_ENABLED` is `true`.

| -| `SOURCEBOT_TENANCY_MODE` | `single` |

The tenancy configuration for Sourcebot. Valid values are `single` or `multi`. See [this doc](/self-hosting/more/tenancy) for more info.

| -| `SOURCEBOT_AUTH_ENABLED` | `false` |

Enables/disables authentication in Sourcebot. If set to `false`, `SOURCEBOT_TENANCY_MODE` must be `single`. See [this doc](/self-hosting/more/authentication) for more info.

| -| `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/self-hosting/security/telemetry) for more info.

| -| `DATA_DIR` | `/data` |

The directory within the container to store all persistent data. Typically, this directory will be volume mapped such that data is persisted across container restarts (e.g., `docker run -v $(pwd):/data`)

| -| `DATA_CACHE_DIR` | `$DATA_DIR/.sourcebot` |

The root data directory in which all data written to disk by Sourcebot will be located.

| -| `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` |

The data directory for the default Postgres database.

| -| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` |

The data directory for the default Redis instance.

| - - -## Additional Features - -There are additional features that can be enabled and configured via environment variables. - - - - - - - - -## Health Check and Version Endpoints - -Sourcebot includes a health check endpoint that indicates if the application is alive, returning `200 OK` if it is: - -```sh -curl http://localhost:3000/api/health -``` - -It also includes a version endpoint to check the current version of the application: - -```sh -curl http://localhost:3000/api/version -``` - -Sample response: - -```json -{ - "version": "v3.0.0" -} -``` \ No newline at end of file diff --git a/docs/self-hosting/configuration/authentication.mdx b/docs/self-hosting/configuration/authentication.mdx new file mode 100644 index 00000000..0ec2a720 --- /dev/null +++ b/docs/self-hosting/configuration/authentication.mdx @@ -0,0 +1,118 @@ +--- +title: Authentication +sidebarTitle: Authentication +--- + +Make sure the `AUTH_URL` environment variable is [configured correctly](/self-hosting/configuration) when using Sourcebot behind a domain. + +Sourcebot has built-in authentication that gates access to your organization. OAuth, email codes, and email / password are supported. + +The first account that's registered on a Sourcebot deployment is made the owner. All other users who register must be [approved](/self-hosting/configuration/authentication#approving-new-members) by the owner. + +![Login Page](/images/login.png) + + +# Approving New Members + +All account registrations after the first account must be approved by the owner. The owner can see all join requests by going into **Settings -> Members**. + +If you have an [enterprise license](/self-hosting/license-key), you can enable [AUTH_EE_ENABLE_JIT_PROVISIONING](/self-hosting/configuration/authentication#enterprise-authentication-providers) to +have Sourcebot accounts automatically created and approved on registration. + +You can setup emails to be sent when new join requests are created/approved by configurating [transactional emails](/self-hosting/configuration/transactional-emails) +# Authentication Providers + +To enable an authentication provider in Sourcebot, configure the required environment variables for the provider. Under the hood, Sourcebot uses Auth.js which supports [many providers](https://authjs.dev/getting-started/authentication/oauth). Submit a [feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas) if you want us to add support for a specific provider. + +## Core Authentication Providers + +### Email / Password +--- +Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`. + +### Email codes +--- +Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables: + +- `AUTH_EMAIL_CODE_LOGIN_ENABLED` +- `SMTP_CONNECTION_URL` +- `EMAIL_FROM_ADDRESS` + + +See [transactional emails](/self-hosting/configuration/transactional-emails) for more details. + +## Enterprise Authentication Providers + +The following authentication providers require an [enterprise license](/self-hosting/license-key) to be enabled. + +By default, a new user registering using these providers must have their join request accepted by the owner of the organization to join. To allow a user to join automatically when +they register for the first time, set the `AUTH_EE_ENABLE_JIT_PROVISIONING` environment variable to `true`. + +### GitHub +--- + +[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github) + +**Required environment variables:** +- `AUTH_EE_GITHUB_CLIENT_ID` +- `AUTH_EE_GITHUB_CLIENT_SECRET` + +Optional environment variables: +- `AUTH_EE_GITHUB_BASE_URL` - Base URL for GitHub Enterprise (defaults to https://github.com) + +### GitLab +--- + +[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab) + +**Required environment variables:** +- `AUTH_EE_GITLAB_CLIENT_ID` +- `AUTH_EE_GITLAB_CLIENT_SECRET` + +Optional environment variables: +- `AUTH_EE_GITLAB_BASE_URL` - Base URL for GitLab instance (defaults to https://gitlab.com) + +### Google +--- + +[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google) + +**Required environment variables:** +- `AUTH_EE_GOOGLE_CLIENT_ID` +- `AUTH_EE_GOOGLE_CLIENT_SECRET` + +### Okta +--- + +[Auth.js Okta Provider Docs](https://authjs.dev/getting-started/providers/okta) + +**Required environment variables:** +- `AUTH_EE_OKTA_CLIENT_ID` +- `AUTH_EE_OKTA_CLIENT_SECRET` +- `AUTH_EE_OKTA_ISSUER` + +### Keycloak +--- + +[Auth.js Keycloak Provider Docs](https://authjs.dev/getting-started/providers/keycloak) + +**Required environment variables:** +- `AUTH_EE_KEYCLOAK_CLIENT_ID` +- `AUTH_EE_KEYCLOAK_CLIENT_SECRET` +- `AUTH_EE_KEYCLOAK_ISSUER` + +### Microsoft Entra ID + +[Auth.js Microsoft Entra ID Provider Docs](https://authjs.dev/getting-started/providers/microsoft-entra-id) + +**Required environment variables:** +- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID` +- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET` +- `AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER` + +--- + +# Troubleshooting + +- If you experience issues logging in, logging out, or accessing an organization you should have access to, try clearing your cookies & performing a full page refresh (`Cmd/Ctrl + Shift + R` on most browsers). +- Still not working? Reach out to us on our [discord](https://discord.com/invite/6Fhp27x7Pb) or [github discussions](https://github.com/sourcebot-dev/sourcebot/discussions) \ No newline at end of file diff --git a/docs/self-hosting/more/declarative-config.mdx b/docs/self-hosting/configuration/declarative-config.mdx similarity index 88% rename from docs/self-hosting/more/declarative-config.mdx rename to docs/self-hosting/configuration/declarative-config.mdx index 233f365c..cdfdb445 100644 --- a/docs/self-hosting/more/declarative-config.mdx +++ b/docs/self-hosting/configuration/declarative-config.mdx @@ -5,10 +5,6 @@ sidebarTitle: Declarative config import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx' - -Declaratively defining `connections` is not available when [multi-tenancy](/self-hosting/more/tenancy) is enabled. - - Some teams require Sourcebot to be configured via a file (where it can be stored in version control, run through CI/CD pipelines, etc.) instead of a web UI. For more information on configuring connections, see this [overview](/docs/connections/overview). diff --git a/docs/self-hosting/configuration/environment-variables.mdx b/docs/self-hosting/configuration/environment-variables.mdx new file mode 100644 index 00000000..8f65fb5e --- /dev/null +++ b/docs/self-hosting/configuration/environment-variables.mdx @@ -0,0 +1,64 @@ +--- +title: Environment Variables +sidebarTitle: Environment Variables +--- + +This page provides a detailed reference of all environment variables supported by Sourcebot. If you're just looking to get up and running, we recommend starting with the [getting started](/self-hosting/overview) guide instead. + +### Core Environment Variables +The following environment variables allow you to configure your Sourcebot deployment. + +| Variable | Default | Description | +| :------- | :------ | :---------- | +| `AUTH_CREDENTIALS_LOGIN_ENABLED` | `true` |

Enables/disables authentication with basic credentials. Username and passwords are stored encrypted at rest within the postgres database. Checkout the [auth docs](/self-hosting/configuration/authentication) for more info

| +| `AUTH_EMAIL_CODE_LOGIN_ENABLED` | `false` |

Enables/disables authentication with a login code that's sent to a users email. `SMTP_CONNECTION_URL` and `EMAIL_FROM_ADDRESS` must also be set. Checkout the [auth docs](/self-hosting/configuration/authentication) for more info

| +| `AUTH_SECRET` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 33` |

Used to validate login session cookies

| +| `AUTH_URL` | - |

URL of your Sourcebot deployment, e.g., `https://example.com` or `http://localhost:3000`.

| +| `CONFIG_PATH` | `-` |

The container relative path to the declerative configuration file. See [this doc](/self-hosting/configuration/declarative-config) for more info.

| +| `DATA_CACHE_DIR` | `$DATA_DIR/.sourcebot` |

The root data directory in which all data written to disk by Sourcebot will be located.

| +| `DATA_DIR` | `/data` |

The directory within the container to store all persistent data. Typically, this directory will be volume mapped such that data is persisted across container restarts (e.g., `docker run -v $(pwd):/data`)

| +| `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` |

The data directory for the default Postgres database.

| +| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` |

Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.

If you'd like to use a non-default schema, you can provide it as a parameter in the database url

| +| `EMAIL_FROM_ADDRESS` | `-` |

The email address that transactional emails will be sent from. See [this doc](/self-hosting/configuration/transactional-emails) for more info.

| +| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` |

The data directory for the default Redis instance.

| +| `REDIS_URL` | `redis://localhost:6379` |

Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.

| +| `SHARD_MAX_MATCH_COUNT` | `10000` |

The maximum shard count per query

| +| `SMTP_CONNECTION_URL` | `-` |

The url to the SMTP service used for sending transactional emails. See [this doc](/self-hosting/configuration/transactional-emails) for more info.

| +| `SOURCEBOT_ENCRYPTION_KEY` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 24` |

Used to encrypt connection secrets and generate API keys.

| +| `SOURCEBOT_LOG_LEVEL` | `info` |

The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.

| +| `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/self-hosting/security/telemetry) for more info.

| +| `TOTAL_MAX_MATCH_COUNT` | `100000` |

The maximum number of matches per query

| +| `ZOEKT_MAX_WALL_TIME_MS` | `10000` |

The maximum real world duration (in milliseconds) per zoekt query

| + +### Enterprise Environment Variables +| Variable | Default | Description | +| :------- | :------ | :---------- | +| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` |

Enables/disables just-in-time user provisioning for SSO providers.

| +| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` |

The base URL for GitHub Enterprise SSO authentication.

| +| `AUTH_EE_GITHUB_CLIENT_ID` | `-` |

The client ID for GitHub Enterprise SSO authentication.

| +| `AUTH_EE_GITHUB_CLIENT_SECRET` | `-` |

The client secret for GitHub Enterprise SSO authentication.

| +| `AUTH_EE_GITLAB_BASE_URL` | `https://gitlab.com` |

The base URL for GitLab Enterprise SSO authentication.

| +| `AUTH_EE_GITLAB_CLIENT_ID` | `-` |

The client ID for GitLab Enterprise SSO authentication.

| +| `AUTH_EE_GITLAB_CLIENT_SECRET` | `-` |

The client secret for GitLab Enterprise SSO authentication.

| +| `AUTH_EE_GOOGLE_CLIENT_ID` | `-` |

The client ID for Google SSO authentication.

| +| `AUTH_EE_GOOGLE_CLIENT_SECRET` | `-` |

The client secret for Google SSO authentication.

| +| `AUTH_EE_KEYCLOAK_CLIENT_ID` | `-` |

The client ID for Keycloak SSO authentication.

| +| `AUTH_EE_KEYCLOAK_CLIENT_SECRET` | `-` |

The client secret for Keycloak SSO authentication.

| +| `AUTH_EE_KEYCLOAK_ISSUER` | `-` |

The issuer URL for Keycloak SSO authentication.

| +| `AUTH_EE_OKTA_CLIENT_ID` | `-` |

The client ID for Okta SSO authentication.

| +| `AUTH_EE_OKTA_CLIENT_SECRET` | `-` |

The client secret for Okta SSO authentication.

| +| `AUTH_EE_OKTA_ISSUER` | `-` |

The issuer URL for Okta SSO authentication.

| + + +### Review Agent Environment Variables +| Variable | Default | Description | +| :------- | :------ | :---------- | +| `GITHUB_APP_ID` | `-` |

The GitHub App ID used for review agent authentication.

| +| `GITHUB_APP_PRIVATE_KEY_PATH` | `-` |

The container relative path to the private key file for the GitHub App used by the review agent.

| +| `GITHUB_APP_WEBHOOK_SECRET` | `-` |

The webhook secret for the GitHub App used by the review agent.

| +| `OPENAI_API_KEY` | `-` |

The OpenAI API key used by the review agent.

| +| `REVIEW_AGENT_API_KEY` | `-` |

The Sourcebot API key used by the review agent.

| +| `REVIEW_AGENT_AUTO_REVIEW_ENABLED` | `false` |

Enables/disables automatic code reviews by the review agent.

| +| `REVIEW_AGENT_LOGGING_ENABLED` | `true` |

Enables/disables logging for the review agent. Logs are saved in `DATA_CACHE_DIR/review-agent`

| +| `REVIEW_AGENT_REVIEW_COMMAND` | `review` |

The command used to trigger a code review by the review agent.

| + diff --git a/docs/self-hosting/more/tenancy.mdx b/docs/self-hosting/configuration/tenancy.mdx similarity index 90% rename from docs/self-hosting/more/tenancy.mdx rename to docs/self-hosting/configuration/tenancy.mdx index bbf3e18e..7dd2cd90 100644 --- a/docs/self-hosting/more/tenancy.mdx +++ b/docs/self-hosting/configuration/tenancy.mdx @@ -4,7 +4,7 @@ sidebarTitle: Multi tenancy --- If you're switching from single-tenant mode, delete the Sourcebot cache (the `.sourcebot` folder) before starting. -[Authentication](/self-hosting/more/authentication) must be enabled to enable multi tenancy mode +[Authentication](/self-hosting/configuration/authentication) must be enabled to enable multi tenancy mode Multi tenancy allows your Sourcebot deployment to have **multiple organizations**, each with their own set of members and repos. To enable multi tenancy mode, define an environment variable named `SOURCEBOT_TENANCY_MODE` and set its value to `multi`. When multi tenancy mode is enabled: diff --git a/docs/self-hosting/more/transactional-emails.mdx b/docs/self-hosting/configuration/transactional-emails.mdx similarity index 76% rename from docs/self-hosting/more/transactional-emails.mdx rename to docs/self-hosting/configuration/transactional-emails.mdx index d84c17b7..9a18cf45 100644 --- a/docs/self-hosting/more/transactional-emails.mdx +++ b/docs/self-hosting/configuration/transactional-emails.mdx @@ -6,9 +6,10 @@ sidebarTitle: Transactional email To enable transactional emails in your deployment, set the following environment variables. We recommend using [Resend](https://resend.com/), but you can use any provider. Setting this enables you to: - Send emails when new members are invited +- Send emails when organization join requests are created/accepted - Log into the Sourcebot deployment using [email codes](self-hosting/more/authentication#email-codes) | Variable | Description | | :------- | :---------- | -| `SMTP_CONNECTION_URL` | SMTP server connection. | +| `SMTP_CONNECTION_URL` | SMTP server connection (`smtp://[user[:password]@]host[:port]`)| | `EMAIL_FROM_ADDRESS` | The sender's email address | \ No newline at end of file diff --git a/docs/self-hosting/license-key.mdx b/docs/self-hosting/license-key.mdx index ea7c99fa..91573df2 100644 --- a/docs/self-hosting/license-key.mdx +++ b/docs/self-hosting/license-key.mdx @@ -19,4 +19,4 @@ docker run \ ## Questions? -If you have any questions regarding licensing, please [contact us](mailto:team@sourcebot.dev). \ No newline at end of file +If you have any questions regarding licensing, please [contact us](https://www.sourcebot.dev/contact). \ No newline at end of file diff --git a/docs/self-hosting/more/authentication.mdx b/docs/self-hosting/more/authentication.mdx deleted file mode 100644 index 78c14657..00000000 --- a/docs/self-hosting/more/authentication.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: Authentication -sidebarTitle: Authentication ---- - -SSO is currently not supported. If you'd like SSO, please reach out using our [contact form](https://www.sourcebot.dev/contact) -If you're switching from non-auth, delete the Sourcebot cache (the `.sourcebot` folder) before starting. - -Sourcebot has built-in authentication that gates access to your organization. OAuth, email codes, and email / password are supported. To enable authentication, set the `SOURCEBOT_AUTH_ENABLED` environment variable to `true`. -When authentication is enabled: - -- [Connection managment](/docs/connections/overview) happens through the UI -- Members must be invited to an organization to gain access -- If you're in single-tenant mode, the first user to register will be made the owner of the default organization. Check out the [roles page](/docs/more/roles-and-permissions) for more info on the different roles and permissions - -![Login Page](/images/login.png) - - -# Authentication Providers - -Make sure the `AUTH_URL` environment variable is [configured correctly](/self-hosting/configuration) when using Sourcebot in a deployed environment. - -To enable an authentication provider in Sourcebot, configure the required environment variables for the provider. Under the hood, Sourcebot uses Auth.js which supports [many providers](https://authjs.dev/getting-started/authentication/oauth). Submit a [feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas) if you want us to add support for a specific provider. - - -## Email / Password ---- -Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`. - -## Email codes ---- -Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables: - -- `SMTP_CONNECTION_URL` -- `EMAIL_FROM_ADDRESS` - - -See [transactional emails](/self-hosting/more/transactional-emails) for more details. - -## GitHub ---- - -[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github) - -**Required environment variables:** -- `AUTH_GITHUB_CLIENT_ID` -- `AUTH_GITHUB_CLIENT_SECRET` - -## Google ---- - -[Auth.js Google Provider Docs](https://next-auth.js.org/providers/google) - -**Required environment variables:** -- `AUTH_GOOGLE_CLIENT_ID` -- `AUTH_GOOGLE_CLIENT_SECRET` - ---- - -# Troubleshooting - -- If you experience issues logging in, logging out, or accessing an organization you should have access to, try clearing your cookies & performing a full page refresh (`Cmd/Ctrl + Shift + R` on most browsers). -- Still not working? Reach out to us on our [discord](https://discord.com/invite/6Fhp27x7Pb) or [github discussions](https://github.com/sourcebot-dev/sourcebot/discussions) \ No newline at end of file diff --git a/docs/self-hosting/overview.mdx b/docs/self-hosting/overview.mdx index 28189b9f..1d8f0f6d 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -47,6 +47,7 @@ Sourcebot is open source and can be self-hosted using our official [Docker image + If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/self-hosting/configuration/environment-variables) environment variable Sourcebot is packaged as a [single Docker image](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). In the same directory as `config.json`, run the following command to start your instance: ``` bash @@ -71,7 +72,16 @@ Sourcebot is open source and can be self-hosted using our official [Docker image - reads `config.json` and starts syncing.
- Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev). + Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev). + + + + Sourcebot has built-in authentication which gates your instance. The first account which is registered on a fresh Sourcebot deployment is made owner. + + Registration is performed using basic credentials which are stored encrypted within your deployment. To setup more authentication providers + check out the [auth docs](/self-hosting/configuration/authentication) + + diff --git a/docs/self-hosting/upgrade/v3-to-v4-guide.mdx b/docs/self-hosting/upgrade/v3-to-v4-guide.mdx new file mode 100644 index 00000000..70024d0d --- /dev/null +++ b/docs/self-hosting/upgrade/v3-to-v4-guide.mdx @@ -0,0 +1,61 @@ +--- +title: V3 to V4 Guide +sidebarTitle: V3 to V4 guide +--- + +This guide will walk you through upgrading your Sourcebot deployment from v3 to v4. + + +Please note that the following features are no longer supported in v4: +- Multi-tenancy mode +- Unauthenticated access to a Sourcebot deployment - authentication is now built in by default. Unauthenticated access to a organization can be enabled with an unlimited seat [enterprise license](/self-hosting/license-key) + + +### If your deployment doesn't have authentication enabled + + + + + If your Sourcebot instance is deployed behind a domain (ex. `https://sourcebot.yourcompany.com`) you **must** set the `AUTH_URL` environment variable to your deployment domain. + + + When you visit your new deployment you'll be presented with a sign-in page. Sourcebot now requires authentication, and all users must register and sign-in to the deployment. + + The first account that's registered will be made the owner. By default, you can register using basic credentials which will be stored encrypted within the postgres DB connected to Sourcebot. Check out + the [auth docs](/self-hosting/configuration/authentication) to setup additional auth providers. + + + + + Emails can be sent on organization join request/approval by configuring [transactional emails](/self-hosting/configuration/transactional-emails) + + + + + After the first account is created, all new account registrations must be approved by the owner. When new users register onto the deployment they'll be presented with the following request approval page: + + ![Pending Approval Page](/images/pending_approval.png) + + The owner can view and approve join requests by navigating to **Settings -> Members** + + + + Congrats, you've successfully migrated to v4! Please let us know what you think of the new features by reaching out on our [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) + + + +### If your deployment has authentication enabled + +The only change that's required if your deployment has authentication enabled is to unset the `SOURCEBOT_AUTH_ENABLED` environment variable. New user registrations will now submit a request to join the organization which can be approved by the owner by +navigating to **Settings -> Members**. Emails can be sent on organization join request/approval by configuring [transactional emails](/self-hosting/configuration/transactional-emails) + +### If your deployment uses multi-tenancy mode + +Unfortunately, multi-tenancy mode is no longer officially supported in v4. To upgrade to v4, you'll need to unset the `SOURCEBOT_TENANCY_MODE` environment variable and wipe your Sourcebot cache. You can then follow the [instructions above](/self-hosting/upgrade/v3-to-v4-guide#if-your-deployment-doesnt-have-authentication-enabled) +to finish upgrading to v4 in single-tenant mode. + +## Troubleshooting +- If you're hitting issues with signing into your Sourcebot instance, make sure you're setting `AUTH_URL` correctly to your deployment domain (ex. `https://sourcebot.yourcompany.com`) + + +Having troubles migrating from v3 to v4? Reach out to us on [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) and we'll try our best to help \ No newline at end of file diff --git a/docs/snippets/bitbucket-app-password.mdx b/docs/snippets/bitbucket-app-password.mdx index ac8a1e27..34ea7361 100644 --- a/docs/snippets/bitbucket-app-password.mdx +++ b/docs/snippets/bitbucket-app-password.mdx @@ -1,6 +1,6 @@ - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` and `user` (username associated with the app password you created) properties to your connection config: ```json @@ -27,7 +27,7 @@ - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your access token: diff --git a/docs/snippets/bitbucket-token.mdx b/docs/snippets/bitbucket-token.mdx index 48f27a87..262aadfc 100644 --- a/docs/snippets/bitbucket-token.mdx +++ b/docs/snippets/bitbucket-token.mdx @@ -1,6 +1,6 @@ - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -25,7 +25,7 @@ - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx index 502b4cb6..11e18f55 100644 --- a/docs/snippets/schemas/v3/index.schema.mdx +++ b/docs/snippets/schemas/v3/index.schema.mdx @@ -63,6 +63,11 @@ "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", + "default": false } }, "additionalProperties": false @@ -172,6 +177,11 @@ "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", + "default": false } }, "additionalProperties": false diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index bd6246a0..545419b5 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -15,4 +15,5 @@ export const DEFAULT_SETTINGS: Settings = { maxRepoGarbageCollectionJobConcurrency: 8, repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours + enablePublicAccess: false, } \ No newline at end of file diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts index f0128297..6da542f5 100644 --- a/packages/crypto/src/index.ts +++ b/packages/crypto/src/index.ts @@ -24,6 +24,28 @@ export function encrypt(text: string): { iv: string; encryptedData: string } { return { iv: iv.toString('hex'), encryptedData: encrypted }; } +export function hashSecret(text: string): string { + if (!SOURCEBOT_ENCRYPTION_KEY) { + throw new Error('Encryption key is not set'); + } + + return crypto.createHmac('sha256', SOURCEBOT_ENCRYPTION_KEY).update(text).digest('hex'); +} + +export function generateApiKey(): { key: string; hash: string } { + if (!SOURCEBOT_ENCRYPTION_KEY) { + throw new Error('Encryption key is not set'); + } + + const secret = crypto.randomBytes(32).toString('hex'); + const hash = hashSecret(secret); + + return { + key: `sourcebot-${secret}`, + hash, + }; +} + export function decrypt(iv: string, encryptedText: string): string { if (!SOURCEBOT_ENCRYPTION_KEY) { throw new Error('Encryption key is not set'); diff --git a/packages/db/prisma/migrations/20250519235121_add_org_metadata_field/migration.sql b/packages/db/prisma/migrations/20250519235121_add_org_metadata_field/migration.sql new file mode 100644 index 00000000..d3f8ec23 --- /dev/null +++ b/packages/db/prisma/migrations/20250519235121_add_org_metadata_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "metadata" JSONB; diff --git a/packages/db/prisma/migrations/20250520021309_add_pending_approval_user/migration.sql b/packages/db/prisma/migrations/20250520021309_add_pending_approval_user/migration.sql new file mode 100644 index 00000000..317ff51e --- /dev/null +++ b/packages/db/prisma/migrations/20250520021309_add_pending_approval_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "pendingApproval" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/db/prisma/migrations/20250520022249_add_account_request/migration.sql b/packages/db/prisma/migrations/20250520022249_add_account_request/migration.sql new file mode 100644 index 00000000..e1b8408f --- /dev/null +++ b/packages/db/prisma/migrations/20250520022249_add_account_request/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "AccountRequest" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "requestedById" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + + CONSTRAINT "AccountRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountRequest_requestedById_key" ON "AccountRequest"("requestedById"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountRequest_requestedById_orgId_key" ON "AccountRequest"("requestedById", "orgId"); + +-- AddForeignKey +ALTER TABLE "AccountRequest" ADD CONSTRAINT "AccountRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccountRequest" ADD CONSTRAINT "AccountRequest_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20250520182630_add_guest_role/migration.sql b/packages/db/prisma/migrations/20250520182630_add_guest_role/migration.sql new file mode 100644 index 00000000..30717976 --- /dev/null +++ b/packages/db/prisma/migrations/20250520182630_add_guest_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "OrgRole" ADD VALUE 'GUEST'; diff --git a/packages/db/prisma/migrations/20250523175553_add_api_key/migration.sql b/packages/db/prisma/migrations/20250523175553_add_api_key/migration.sql new file mode 100644 index 00000000..115e3f8a --- /dev/null +++ b/packages/db/prisma/migrations/20250523175553_add_api_key/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "ApiKey" ( + "name" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + "orgId" INTEGER NOT NULL, + "createdById" TEXT NOT NULL, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("hash") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_hash_key" ON "ApiKey"("hash"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ed14810a..0619dcd6 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -136,6 +136,20 @@ model Invite { @@unique([recipientEmail, orgId]) } +model AccountRequest { + id String @id @default(cuid()) + + createdAt DateTime @default(now()) + + requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade) + requestedById String @unique + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@unique([requestedById, orgId]) +} + model Org { id Int @id @default(autoincrement()) name String @@ -146,8 +160,10 @@ model Org { connections Connection[] repos Repo[] secrets Secret[] + apiKeys ApiKey[] isOnboarded Boolean @default(false) imageUrl String? + metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts stripeCustomerId String? stripeSubscriptionStatus StripeSubscriptionStatus? @@ -156,12 +172,15 @@ model Org { /// List of pending invites to this organization invites Invite[] + accountRequests AccountRequest[] + searchContexts SearchContext[] } enum OrgRole { OWNER MEMBER + GUEST } model UserToOrg { @@ -193,20 +212,39 @@ model Secret { @@id([orgId, key]) } +model ApiKey { + name String + hash String @id @unique + + createdAt DateTime @default(now()) + lastUsedAt DateTime? + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById String + +} + // @see : https://authjs.dev/concepts/database-models#user model User { - id String @id @default(cuid()) - name String? - email String? @unique - hashedPassword String? - emailVerified DateTime? - image String? - accounts Account[] - orgs UserToOrg[] + id String @id @default(cuid()) + name String? + email String? @unique + hashedPassword String? + emailVerified DateTime? + image String? + accounts Account[] + orgs UserToOrg[] + pendingApproval Boolean @default(true) + accountRequest AccountRequest? /// List of pending invites that the user has created invites Invite[] + apiKeys ApiKey[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index d63d895f..40e3fbc9 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Added API key support. [#311](https://github.com/sourcebot-dev/sourcebot/pull/311) + # [1.0.1] - 2025-05-15 ### Changed diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index bcb0fd8e..ffde2d7b 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -9,7 +9,8 @@ export const search = async (request: SearchRequest): Promise response.json()); @@ -26,7 +27,8 @@ export const listRepos = async (): Promise response.json()); @@ -42,7 +44,8 @@ export const getFileSource = async (request: FileSourceRequest): Promise response.json()); diff --git a/packages/mcp/src/env.ts b/packages/mcp/src/env.ts index 35559f7d..d4cac622 100644 --- a/packages/mcp/src/env.ts +++ b/packages/mcp/src/env.ts @@ -9,6 +9,8 @@ export const env = createEnv({ server: { SOURCEBOT_HOST: z.string().url().default(SOURCEBOT_DEMO_HOST), + SOURCEBOT_API_KEY: z.string().optional(), + // The minimum number of tokens to return DEFAULT_MINIMUM_TOKENS: numberSchema.default(10000), diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index f2c69d12..00933bd5 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -21,6 +21,7 @@ server.tool( "search_code", `Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search. Results are returned as an array of matching files, with the file's URL, repository, and language. + If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable. If the \`includeCodeSnippets\` property is true, code snippets containing the matches will be included in the response. Only set this to true if the request requires code snippets (e.g., show me examples where library X is used). When referencing a file in your response, **ALWAYS** include the file's external URL as a link. This makes it easier for the user to view the file, even if they don't have it locally checked out. **ONLY USE** the \`filterByRepoIds\` property if the request requires searching a specific repo(s). Otherwise, leave it empty.`, @@ -151,7 +152,7 @@ server.tool( server.tool( "list_repos", - "Lists all repositories in the organization.", + "Lists all repositories in the organization. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.", async () => { const response = await listRepos(); if (isServiceError(response)) { @@ -178,7 +179,7 @@ server.tool( server.tool( "get_file_source", - "Fetches the source code for a given file.", + "Fetches the source code for a given file. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.", { fileName: z.string().describe("The file to fetch the source code for."), repoId: z.string().describe("The repository to fetch the source code for. This is the Sourcebot compatible repository ID."), diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index 52421b6f..925b27a8 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -62,6 +62,11 @@ const schema = { "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", + "default": false } }, "additionalProperties": false @@ -171,6 +176,11 @@ const schema = { "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", + "default": false } }, "additionalProperties": false diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index 70c035b0..4506c611 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -79,6 +79,10 @@ export interface Settings { * The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours. */ repoIndexTimeoutMs?: number; + /** + * [Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats. + */ + enablePublicAccess?: boolean; } /** * Search context diff --git a/packages/web/.eslintrc.json b/packages/web/.eslintrc.json index 6b1e43a1..1808f80a 100644 --- a/packages/web/.eslintrc.json +++ b/packages/web/.eslintrc.json @@ -7,7 +7,8 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", - "next/core-web-vitals" + "next/core-web-vitals", + "plugin:@tanstack/query/recommended" ], "rules": { "react-hooks/exhaustive-deps": "warn", diff --git a/packages/web/package.json b/packages/web/package.json index 39aebf65..15e17fe4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,6 +34,7 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.0.0", + "@codemirror/language-data": "^6.5.1", "@codemirror/legacy-modes": "^6.4.2", "@codemirror/search": "^6.5.6", "@codemirror/state": "^6.4.1", @@ -44,6 +45,7 @@ "@iizukak/codemirror-lang-wgsl": "^0.3.0", "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.6", @@ -55,6 +57,7 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.0", @@ -79,6 +82,7 @@ "@tanstack/react-query": "^5.53.3", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.10.8", + "@uidotdev/usehooks": "^2.4.1", "@uiw/codemirror-themes": "^4.23.6", "@uiw/react-codemirror": "^4.23.0", "@viz-js/lang-dot": "^1.0.4", @@ -142,6 +146,7 @@ "zod-to-json-schema": "^3.24.5" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.74.7", "@types/micromatch": "^4.0.9", "@types/node": "^20", "@types/nodemailer": "^6.4.17", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index a2ed5119..3c4c37c6 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -2,13 +2,13 @@ import { env } from "@/env.mjs"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError"; +import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; import { CodeHostType, isServiceError } from "@/lib/utils"; import { prisma } from "@/prisma"; import { render } from "@react-email/components"; import * as Sentry from '@sentry/nextjs'; -import { decrypt, encrypt } from "@sourcebot/crypto"; -import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; +import { decrypt, encrypt, generateApiKey, hashSecret } from "@sourcebot/crypto"; +import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus, Org, ApiKey } from "@sourcebot/db"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; @@ -16,19 +16,23 @@ import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; import Ajv from "ajv"; import { StatusCodes } from "http-status-codes"; -import { Session } from "next-auth"; import { cookies, headers } from "next/headers"; import { createTransport } from "nodemailer"; import { auth } from "./auth"; import { getConnection } from "./data/connection"; import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe"; import InviteUserEmail from "./emails/inviteUserEmail"; -import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants"; +import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; -import { TenancyMode } from "./lib/types"; +import { TenancyMode, ApiKeyPayload } from "./lib/types"; import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils"; import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema"; +import { getPlan, getSeats, SOURCEBOT_UNLIMITED_SEATS } from "./features/entitlements/server"; +import { hasEntitlement } from "./features/entitlements/server"; +import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess"; +import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; +import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; const ajv = new Ajv({ validateFormats: false, @@ -50,33 +54,62 @@ export const sew = async (fn: () => Promise): Promise => } } -export const withAuth = async (fn: (session: Session) => Promise, allowSingleTenantUnauthedAccess: boolean = false) => { +export const withAuth = async (fn: (userId: string) => Promise, allowSingleTenantUnauthedAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => { const session = await auth(); + if (!session) { - if ( - env.SOURCEBOT_TENANCY_MODE === 'single' && - env.SOURCEBOT_AUTH_ENABLED === 'false' && - allowSingleTenantUnauthedAccess === true - ) { - // To allow for unauthed acccess in single-tenant mode, we can - // create a fake session with the default user. This user has membership - // in the default org. - // @see: initialize.ts - return fn({ - user: { - id: SINGLE_TENANT_USER_ID, - email: SINGLE_TENANT_USER_EMAIL, + // First we check if public access is enabled and supported. If not, then we check if an api key was provided. If not, + // then this is an invalid unauthed request and we return a 401. + const publicAccessEnabled = await getPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN); + if (apiKey) { + const apiKeyOrError = await verifyApiKey(apiKey); + if (isServiceError(apiKeyOrError)) { + console.error(`Invalid API key: ${JSON.stringify(apiKey)}. Error: ${JSON.stringify(apiKeyOrError)}`); + return notAuthenticated(); + } + + const user = await prisma.user.findUnique({ + where: { + id: apiKeyOrError.apiKey.createdById, + }, + }); + + if (!user) { + console.error(`No user found for API key: ${apiKey}`); + return notAuthenticated(); + } + + await prisma.apiKey.update({ + where: { + hash: apiKeyOrError.apiKey.hash, + }, + data: { + lastUsedAt: new Date(), }, - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), }); - } + return fn(user.id); + } else if ( + env.SOURCEBOT_TENANCY_MODE === 'single' && + allowSingleTenantUnauthedAccess && + !isServiceError(publicAccessEnabled) && + publicAccessEnabled + ) { + if (!hasEntitlement("public-access")) { + const plan = getPlan(); + console.error(`Public access isn't 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}.`); + return notAuthenticated(); + } + + // To support unauthed access a guest user is created in initialize.ts, which we return here + return fn(SOURCEBOT_GUEST_USER_ID); + } return notAuthenticated(); } - return fn(session); + return fn(session.user.id); } -export const withOrgMembership = async (session: Session, domain: string, fn: (params: { orgId: number, userRole: OrgRole }) => Promise, minRequiredRole: OrgRole = OrgRole.MEMBER) => { +export const orgHasAvailability = async (domain: string): Promise => { const org = await prisma.org.findUnique({ where: { domain, @@ -84,28 +117,59 @@ export const withOrgMembership = async (session: Session, domain: string, fn: }); if (!org) { - return notFound(); + return false; + } + const members = await prisma.userToOrg.findMany({ + where: { + orgId: org.id, + role: { + not: OrgRole.GUEST, + }, + }, + }); + + const maxSeats = getSeats(); + const memberCount = members.length; + + if (maxSeats !== SOURCEBOT_UNLIMITED_SEATS && memberCount >= maxSeats) { + return false; + } + + return true; +} + +export const withOrgMembership = async (userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise, minRequiredRole: OrgRole = OrgRole.MEMBER) => { + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org) { + return notFound("Organization not found"); } const membership = await prisma.userToOrg.findUnique({ where: { orgId_userId: { - userId: session.user.id, + userId, orgId: org.id, } }, }); if (!membership) { - return notFound(); + return notFound("User not a member of this organization"); } const getAuthorizationPrecendence = (role: OrgRole): number => { switch (role) { - case OrgRole.MEMBER: + case OrgRole.GUEST: return 0; - case OrgRole.OWNER: + case OrgRole.MEMBER: return 1; + case OrgRole.OWNER: + return 2; } } @@ -119,7 +183,7 @@ export const withOrgMembership = async (session: Session, domain: string, fn: } return fn({ - orgId: org.id, + org: org, userRole: membership.role, }); } @@ -139,7 +203,7 @@ export const withTenancyModeEnforcement = async(mode: TenancyMode, fn: () => export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => withTenancyModeEnforcement('multi', () => - withAuth(async (session) => { + withAuth(async (userId) => { const org = await prisma.org.create({ data: { name, @@ -149,7 +213,7 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number } role: "OWNER", user: { connect: { - id: session.user.id, + id: userId, } } } @@ -163,8 +227,8 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number } }))); export const updateOrgName = async (name: string, domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const { success } = orgNameSchema.safeParse(name); if (!success) { return { @@ -175,7 +239,7 @@ export const updateOrgName = async (name: string, domain: string) => sew(() => } await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { name }, }); @@ -187,8 +251,8 @@ export const updateOrgName = async (name: string, domain: string) => sew(() => export const updateOrgDomain = async (newDomain: string, existingDomain: string) => sew(() => withTenancyModeEnforcement('multi', () => - withAuth((session) => - withOrgMembership(session, existingDomain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, existingDomain, async ({ org }) => { const { success } = await orgDomainSchema.safeParseAsync(newDomain); if (!success) { return { @@ -199,7 +263,7 @@ export const updateOrgDomain = async (newDomain: string, existingDomain: string) } await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { domain: newDomain }, }); @@ -210,20 +274,12 @@ export const updateOrgDomain = async (newDomain: string, existingDomain: string) ))); export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { id: orgId }, - }); - - if (!org) { - return notFound(); - } - + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { // If billing is not enabled, we can just mark the org as onboarded. if (!IS_BILLING_ENABLED) { await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { isOnboarded: true, } @@ -231,13 +287,13 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo // Else, validate that the org has an active subscription. } else { - const subscriptionOrError = await getSubscriptionForOrg(orgId, prisma); + const subscriptionOrError = await getSubscriptionForOrg(org.id, prisma); if (isServiceError(subscriptionOrError)) { return subscriptionOrError; } await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { isOnboarded: true, stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE, @@ -253,11 +309,11 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo )); export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const secrets = await prisma.secret.findMany({ where: { - orgId, + orgId: org.id, }, select: { key: true, @@ -272,13 +328,13 @@ export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: stri }))); export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const encrypted = encrypt(value); const existingSecret = await prisma.secret.findUnique({ where: { orgId_key: { - orgId, + orgId: org.id, key, } } @@ -290,13 +346,13 @@ export const createSecret = async (key: string, value: string, domain: string): await prisma.secret.create({ data: { - orgId, + orgId: org.id, key, encryptedValue: encrypted.encryptedData, iv: encrypted.iv, } }); - + return { success: true, @@ -304,12 +360,12 @@ export const createSecret = async (key: string, value: string, domain: string): }))); export const checkIfSecretExists = async (key: string, domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const secret = await prisma.secret.findUnique({ where: { orgId_key: { - orgId, + orgId: org.id, key, } } @@ -319,12 +375,12 @@ export const checkIfSecretExists = async (key: string, domain: string): Promise< }))); export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { await prisma.secret.delete({ where: { orgId_key: { - orgId, + orgId: org.id, key, } } @@ -335,13 +391,147 @@ export const deleteSecret = async (key: string, domain: string): Promise<{ succe } }))); +export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiKey: ApiKey } | ServiceError> => sew(async () => { + const parts = apiKeyPayload.apiKey.split("-"); + if (parts.length !== 2 || parts[0] !== "sourcebot") { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_API_KEY, + message: "Invalid API key", + } satisfies ServiceError; + } + + const hash = hashSecret(parts[1]) + const apiKey = await prisma.apiKey.findUnique({ + where: { + hash, + }, + }); + + if (!apiKey) { + return { + statusCode: StatusCodes.UNAUTHORIZED, + errorCode: ErrorCode.INVALID_API_KEY, + message: "Invalid API key", + } satisfies ServiceError; + } + + const apiKeyTargetOrg = await prisma.org.findUnique({ + where: { + domain: apiKeyPayload.domain, + }, + }); + + if (!apiKeyTargetOrg) { + return { + statusCode: StatusCodes.UNAUTHORIZED, + errorCode: ErrorCode.INVALID_API_KEY, + message: `Invalid API key payload. Provided domain ${apiKeyPayload.domain} does not exist.`, + } satisfies ServiceError; + } + + if (apiKey.orgId !== apiKeyTargetOrg.id) { + return { + statusCode: StatusCodes.UNAUTHORIZED, + errorCode: ErrorCode.INVALID_API_KEY, + message: `Invalid API key payload. Provided domain ${apiKeyPayload.domain} does not match the API key's org.`, + } satisfies ServiceError; + } + + return { + apiKey, + } +}); + + +export const createApiKey = async (name: string, domain: string): Promise<{ key: string } | ServiceError> => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const existingApiKey = await prisma.apiKey.findFirst({ + where: { + createdById: userId, + name, + }, + }); + + if (existingApiKey) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.API_KEY_ALREADY_EXISTS, + message: `API key ${name} already exists`, + } satisfies ServiceError; + } + + const { key, hash } = generateApiKey(); + await prisma.apiKey.create({ + data: { + name, + hash, + orgId: org.id, + createdById: userId, + } + }); + + return { + key, + } + }))); + +export const deleteApiKey = async (name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async () => { + const apiKey = await prisma.apiKey.findFirst({ + where: { + name, + createdById: userId, + }, + }); + + if (!apiKey) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.API_KEY_NOT_FOUND, + message: `API key ${name} not found for user ${userId}`, + } satisfies ServiceError; + } + + await prisma.apiKey.delete({ + where: { + hash: apiKey.hash, + }, + }); + + return { + success: true, + } + }))); + +export const getUserApiKeys = async (domain: string): Promise<{ name: string; createdAt: Date; lastUsedAt: Date | null }[] | ServiceError> => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const apiKeys = await prisma.apiKey.findMany({ + where: { + orgId: org.id, + createdById: userId, + }, + orderBy: { + createdAt: 'desc', + } + }); + + return apiKeys.map((apiKey) => ({ + name: apiKey.name, + createdAt: apiKey.createdAt, + lastUsedAt: apiKey.lastUsedAt, + })); + }))); export const getConnections = async (domain: string, filter: { status?: ConnectionSyncStatus[] } = {}) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const connections = await prisma.connection.findMany({ where: { - orgId, + orgId: org.id, ...(filter.status ? { syncStatus: { in: filter.status } } : {}), @@ -369,15 +559,16 @@ export const getConnections = async (domain: string, filter: { status?: Connecti repoIndexingStatus: repo.repoIndexingStatus, })), })); - }), /* allowSingleTenantUnauthedAccess = */ true)); + }) + )); export const getConnectionInfo = async (connectionId: number, domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const connection = await prisma.connection.findUnique({ where: { id: connectionId, - orgId, + orgId: org.id, }, include: { repos: true, @@ -401,11 +592,11 @@ export const getConnectionInfo = async (connectionId: number, domain: string) => }))); export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const repos = await prisma.repo.findMany({ where: { - orgId, + orgId: org.id, ...(filter.status ? { repoIndexingStatus: { in: filter.status } } : {}), @@ -441,12 +632,12 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt indexedAt: repo.indexedAt ?? undefined, repoIndexingStatus: repo.repoIndexingStatus, })); - } - ), /* allowSingleTenantUnauthedAccess = */ true)); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true + )); export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { // @note: repo names are represented by their remote url // on the code host. E.g.,: // - github.com/sourcebot-dev/sourcebot @@ -485,7 +676,7 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew const repo = await prisma.repo.findFirst({ where: { name: repoName, - orgId, + orgId: org.id, }, }); @@ -503,11 +694,20 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew indexedAt: repo.indexedAt ?? undefined, repoIndexingStatus: repo.repoIndexingStatus, } - }), /* allowSingleTenantUnauthedAccess = */ true)); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true + )); export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + if (env.CONFIG_PATH !== undefined) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.CONNECTION_CONFIG_PATH_SET, + message: "A configuration file has been provided. New connections cannot be added through the web interface.", + } satisfies ServiceError; + } + const parsedConfig = parseConnectionConfig(connectionConfig); if (isServiceError(parsedConfig)) { return parsedConfig; @@ -516,7 +716,7 @@ export const createConnection = async (name: string, type: CodeHostType, connect const existingConnectionWithName = await prisma.connection.findUnique({ where: { name_orgId: { - orgId, + orgId: org.id, name, } } @@ -532,7 +732,7 @@ export const createConnection = async (name: string, type: CodeHostType, connect const connection = await prisma.connection.create({ data: { - orgId, + orgId: org.id, name, config: parsedConfig as unknown as Prisma.InputJsonValue, connectionType: type, @@ -546,9 +746,9 @@ export const createConnection = async (name: string, type: CodeHostType, connect )); export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const connection = await getConnection(connectionId, orgId); + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const connection = await getConnection(connectionId, org.id); if (!connection) { return notFound(); } @@ -556,7 +756,7 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st const existingConnectionWithName = await prisma.connection.findUnique({ where: { name_orgId: { - orgId, + orgId: org.id, name, } } @@ -573,7 +773,7 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st await prisma.connection.update({ where: { id: connectionId, - orgId, + orgId: org.id, }, data: { name, @@ -587,9 +787,9 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st )); export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const connection = await getConnection(connectionId, orgId); + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const connection = await getConnection(connectionId, org.id); if (!connection) { return notFound(); } @@ -612,7 +812,7 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number await prisma.connection.update({ where: { id: connectionId, - orgId, + orgId: org.id, }, data: { config: parsedConfig as unknown as Prisma.InputJsonValue, @@ -627,10 +827,10 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number )); export const flagConnectionForSync = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const connection = await getConnection(connectionId, orgId); - if (!connection || connection.orgId !== orgId) { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const connection = await getConnection(connectionId, org.id); + if (!connection || connection.orgId !== org.id) { return notFound(); } @@ -650,12 +850,12 @@ export const flagConnectionForSync = async (connectionId: number, domain: string )); export const flagReposForIndex = async (repoIds: number[], domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { await prisma.repo.updateMany({ where: { id: { in: repoIds }, - orgId, + orgId: org.id, }, data: { repoIndexingStatus: RepoIndexingStatus.NEW, @@ -669,9 +869,9 @@ export const flagReposForIndex = async (repoIds: number[], domain: string) => se )); export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const connection = await getConnection(connectionId, orgId); + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const connection = await getConnection(connectionId, org.id); if (!connection) { return notFound(); } @@ -679,7 +879,7 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr await prisma.connection.delete({ where: { id: connectionId, - orgId, + orgId: org.id, } }); @@ -690,22 +890,36 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr )); export const getCurrentUserRole = async (domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ userRole }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ userRole }) => { return userRole; - }) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true )); export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const user = await getMe(); + if (isServiceError(user)) { + throw new ServiceErrorException(user); + } + + const hasAvailability = await orgHasAvailability(domain); + if (!hasAvailability) { + 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, + orgId: org.id, } }); @@ -725,7 +939,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ in: emails, } }, - orgId, + orgId: org.id, }, }); @@ -740,8 +954,8 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ await prisma.invite.createMany({ data: emails.map((email) => ({ recipientEmail: email, - hostUserId: session.user.id, - orgId, + hostUserId: userId, + orgId: org.id, })), skipDuplicates: true, }); @@ -754,7 +968,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ where: { recipientEmail_orgId: { recipientEmail: email, - orgId, + orgId: org.id, }, }, include: { @@ -774,11 +988,10 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ const inviteLink = `${origin}/redeem?invite_id=${invite.id}`; const transport = createTransport(env.SMTP_CONNECTION_URL); const html = await render(InviteUserEmail({ - baseUrl: origin, host: { - name: session.user.name ?? undefined, - email: session.user.email!, - avatarUrl: session.user.image ?? undefined, + name: user.name ?? undefined, + email: user.email!, + avatarUrl: user.image ?? undefined, }, recipient: { name: recipient?.name ?? undefined, @@ -801,6 +1014,8 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ console.error(`Failed to send invite email to ${email}: ${failed}`); } })); + } else { + console.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`); } return { @@ -810,12 +1025,12 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ )); export const cancelInvite = async (inviteId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const invite = await prisma.invite.findUnique({ where: { id: inviteId, - orgId, + orgId: org.id, }, }); @@ -836,10 +1051,10 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{ )); export const getMe = async () => sew(() => - withAuth(async (session) => { + withAuth(async (userId) => { const user = await prisma.user.findUnique({ where: { - id: session.user.id, + id: userId, }, include: { orgs: { @@ -858,6 +1073,7 @@ export const getMe = async () => sew(() => id: user.id, email: user.email, name: user.name, + image: user.image, memberships: user.orgs.map((org) => ({ id: org.orgId, role: org.role, @@ -887,6 +1103,15 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean return user; } + const hasAvailability = await orgHasAvailability(invite.org.domain); + if (!hasAvailability) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Organization is at max capacity", + } satisfies ServiceError; + } + // Check if the user is the recipient of the invite if (user.email !== invite.recipientEmail) { return notFound(); @@ -901,6 +1126,15 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } }); + await tx.user.update({ + where: { + id: user.id, + }, + data: { + pendingApproval: false, + } + }); + await tx.invite.delete({ where: { id: invite.id, @@ -967,9 +1201,9 @@ export const getInviteInfo = async (inviteId: string) => sew(() => })); export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const currentUserId = session.user.id; + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const currentUserId = userId; if (newOwnerId === currentUserId) { return { @@ -983,7 +1217,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro where: { orgId_userId: { userId: newOwnerId, - orgId, + orgId: org.id, }, }, }); @@ -1001,7 +1235,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro where: { orgId_userId: { userId: newOwnerId, - orgId, + orgId: org.id, }, }, data: { @@ -1012,7 +1246,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro where: { orgId_userId: { userId: currentUserId, - orgId, + orgId: org.id, }, }, data: { @@ -1039,12 +1273,12 @@ export const checkIfOrgDomainExists = async (domain: string): Promise => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { const targetMember = await prisma.userToOrg.findUnique({ where: { orgId_userId: { - orgId, + orgId: org.id, userId: memberId, } } @@ -1054,28 +1288,31 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro return notFound(); } - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - return notFound(); - } - await prisma.$transaction(async (tx) => { await tx.userToOrg.delete({ where: { orgId_userId: { - orgId, + orgId: org.id, userId: memberId, } } }); + // TODO: The fact that pendingApproval is set in the user is a bit weird here, since it will prevent approval from working in the multi-tenant case. + // We need to set pendingApproval to be true here though so that if the user tries to sign into the deployment again it will send another request. Without + // this, the user will never be able to request to join the org again. + // TODO(multitenant): Handle this better + await tx.user.update({ + where: { + id: memberId, + }, + data: { + pendingApproval: true, + } + }); + if (IS_BILLING_ENABLED) { - const result = await decrementOrgSeatCount(orgId, tx); + const result = await decrementOrgSeatCount(org.id, tx); if (isServiceError(result)) { throw result; } @@ -1089,8 +1326,8 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro )); export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId, userRole }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org, userRole }) => { if (userRole === OrgRole.OWNER) { return { statusCode: StatusCodes.FORBIDDEN, @@ -1099,28 +1336,18 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S } satisfies ServiceError; } - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - return notFound(); - } - await prisma.$transaction(async (tx) => { await tx.userToOrg.delete({ where: { orgId_userId: { - orgId, - userId: session.user.id, + orgId: org.id, + userId: userId, } } }); if (IS_BILLING_ENABLED) { - const result = await decrementOrgSeatCount(orgId, tx); + const result = await decrementOrgSeatCount(org.id, tx); if (isServiceError(result)) { throw result; } @@ -1133,14 +1360,15 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S }) )); + export const getOrgMembership = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { const membership = await prisma.userToOrg.findUnique({ where: { orgId_userId: { - orgId, - userId: session.user.id, + orgId: org.id, + userId: userId, } } }); @@ -1154,11 +1382,14 @@ export const getOrgMembership = async (domain: string) => sew(() => )); export const getOrgMembers = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { const members = await prisma.userToOrg.findMany({ where: { - orgId, + orgId: org.id, + role: { + not: OrgRole.GUEST, + } }, include: { user: true, @@ -1177,11 +1408,11 @@ export const getOrgMembers = async (domain: string) => sew(() => )); export const getOrgInvites = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { const invites = await prisma.invite.findMany({ where: { - orgId, + orgId: org.id, }, }); @@ -1193,17 +1424,281 @@ export const getOrgInvites = async (domain: string) => sew(() => }) )); +export const getOrgAccountRequests = async (domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + 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, + })); + }) + )); + +export const createAccountRequest = async (userId: string, domain: string) => sew(async () => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) { + return notFound("User not found"); + } + + if (user.pendingApproval == false) { + console.warn(`User ${userId} isn't pending approval. Skipping account request creation.`); + return { + success: true, + existingRequest: false, + } + } + + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org) { + return notFound("Organization not found"); + } + + const existingRequest = await prisma.accountRequest.findUnique({ + where: { + requestedById_orgId: { + requestedById: userId, + orgId: org.id, + }, + }, + }); + + if (existingRequest) { + console.warn(`User ${userId} already has an account request for org ${org.id}. Skipping account request creation.`); + return { + success: true, + existingRequest: true, + } + } + + if (!existingRequest) { + await prisma.accountRequest.create({ + data: { + requestedById: userId, + orgId: org.id, + }, + }); + + if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) { + // TODO: This is needed because we can't fetch the origin from the request headers when this is called + // on user creation (the header isn't set when next-auth calls onCreateUser for some reason) + const deploymentUrl = env.AUTH_URL; + + const owner = await prisma.user.findFirst({ + where: { + orgs: { + some: { + orgId: org.id, + role: "OWNER", + }, + }, + }, + }); + + if (!owner) { + console.error(`Failed to find owner for org ${org.id} when drafting email for account request from ${userId}`); + } else { + const html = await render(JoinRequestSubmittedEmail({ + baseUrl: deploymentUrl, + requestor: { + name: user.name ?? undefined, + email: user.email!, + avatarUrl: user.image ?? undefined, + }, + orgName: org.name, + orgDomain: org.domain, + orgImageUrl: org.imageUrl ?? undefined, + })); + + const transport = createTransport(env.SMTP_CONNECTION_URL); + const result = await transport.sendMail({ + to: owner.email!, + from: env.EMAIL_FROM_ADDRESS, + subject: `New account request for ${org.name} on Sourcebot`, + html, + text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`, + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + console.error(`Failed to send account request email to ${owner.email}: ${failed}`); + } + } + } else { + console.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); + } + } + + return { + success: true, + existingRequest: false, + } +}); + +export const approveAccountRequest = async (requestId: string, domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const request = await prisma.accountRequest.findUnique({ + where: { + id: requestId, + }, + include: { + requestedBy: true, + }, + }); + + if (!request || request.orgId !== org.id) { + return notFound(); + } + + const hasAvailability = await orgHasAvailability(domain); + if (!hasAvailability) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Organization is at max capacity", + } satisfies ServiceError; + } + + const res = await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { + id: request.requestedById, + }, + data: { + pendingApproval: false, + }, + }); + + await tx.userToOrg.create({ + data: { + userId: request.requestedById, + orgId: org.id, + role: "MEMBER", + }, + }); + + await tx.accountRequest.delete({ + where: { + id: requestId, + }, + }); + + const invites = await tx.invite.findMany({ + where: { + recipientEmail: request.requestedBy.email!, + orgId: org.id, + }, + }) + + for (const invite of invites) { + console.log(`Account request approved. Deleting invite ${invite.id} for ${request.requestedBy.email}`); + await tx.invite.delete({ + where: { + id: invite.id, + }, + }); + } + }); + + if (isServiceError(res)) { + return res; + } + + // Send approval email to the user + if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) { + const origin = (await headers()).get('origin')!; + + const html = await render(JoinRequestApprovedEmail({ + baseUrl: origin, + user: { + name: request.requestedBy.name ?? undefined, + email: request.requestedBy.email!, + avatarUrl: request.requestedBy.image ?? undefined, + }, + orgName: org.name, + orgDomain: org.domain + })); + + const transport = createTransport(env.SMTP_CONNECTION_URL); + 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 ${origin}/${org.domain}`, + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + console.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); + } + } else { + console.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); + } + + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); + +export const rejectAccountRequest = async (requestId: string, domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + 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, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); + export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => { await cookies().set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true'); return true; }); export const getSearchContexts = async (domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const searchContexts = await prisma.searchContext.findMany({ where: { - orgId, + orgId: org.id, }, }); @@ -1211,8 +1706,8 @@ export const getSearchContexts = async (domain: string) => sew(() => name: context.name, description: context.description ?? undefined, })); - } - ), /* allowSingleTenantUnauthedAccess = */ true)); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true + )); ////// Helpers /////// @@ -1275,7 +1770,7 @@ const parseConnectionConfig = (config: string) => { const { numRepos, hasToken } = (() => { switch (connectionType) { case "gitea": - case "github": + case "github": case "bitbucket": { return { numRepos: parsedConfig.repos?.length, diff --git a/packages/web/src/app/[domain]/browse/README.md b/packages/web/src/app/[domain]/browse/README.md new file mode 100644 index 00000000..8613d6da --- /dev/null +++ b/packages/web/src/app/[domain]/browse/README.md @@ -0,0 +1,12 @@ +# File browser + +This directory contains Sourcebot's file browser implementation. URL paths are used to determine what file the user wants to view. The following template is used: + +```sh +/browse/[@]/-/(blob|tree)/ +``` + +For example, to view `packages/backend/src/env.ts` in Sourcebot, we would use the following path: +```sh +/browse/github.com/sourcebot-dev/sourcebot@HEAD/-/blob/packages/backend/src/env.ts +``` diff --git a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx deleted file mode 100644 index 8f6243c7..00000000 --- a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx +++ /dev/null @@ -1,150 +0,0 @@ -'use client'; - -import { ScrollArea } from "@/components/ui/scroll-area"; -import { useKeymapExtension } from "@/hooks/useKeymapExtension"; -import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; -import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; -import { search } from "@codemirror/search"; -import CodeMirror, { Decoration, DecorationSet, EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, StateField, ViewUpdate } from "@uiw/react-codemirror"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { EditorContextMenu } from "../../components/editorContextMenu"; -import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; - -interface CodePreviewProps { - path: string; - repoName: string; - revisionName: string; - source: string; - language: string; -} - -export const CodePreview = ({ - source, - language, - path, - repoName, - revisionName, -}: CodePreviewProps) => { - const editorRef = useRef(null); - const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view); - const [currentSelection, setCurrentSelection] = useState(); - const keymapExtension = useKeymapExtension(editorRef.current?.view); - const [isEditorCreated, setIsEditorCreated] = useState(false); - - const highlightRangeQuery = useNonEmptyQueryParam('highlightRange'); - const highlightRange = useMemo(() => { - if (!highlightRangeQuery) { - return; - } - - const rangeRegex = /^\d+:\d+,\d+:\d+$/; - if (!rangeRegex.test(highlightRangeQuery)) { - return; - } - - const [start, end] = highlightRangeQuery.split(',').map((range) => { - return range.split(':').map((val) => parseInt(val, 10)); - }); - - return { - start: { - line: start[0], - character: start[1], - }, - end: { - line: end[0], - character: end[1], - } - } - }, [highlightRangeQuery]); - - const extensions = useMemo(() => { - const highlightDecoration = Decoration.mark({ - class: "cm-searchMatch-selected", - }); - - return [ - syntaxHighlighting, - EditorView.lineWrapping, - keymapExtension, - search({ - top: true, - }), - EditorView.updateListener.of((update: ViewUpdate) => { - if (update.selectionSet) { - setCurrentSelection(update.state.selection.main); - } - }), - StateField.define({ - create(state) { - if (!highlightRange) { - return Decoration.none; - } - - const { start, end } = highlightRange; - const from = state.doc.line(start.line).from + start.character - 1; - const to = state.doc.line(end.line).from + end.character - 1; - - return Decoration.set([ - highlightDecoration.range(from, to), - ]); - }, - update(deco, tr) { - return deco.map(tr.changes); - }, - provide: (field) => EditorView.decorations.from(field), - }), - ]; - }, [keymapExtension, syntaxHighlighting, highlightRange]); - - useEffect(() => { - if (!highlightRange || !editorRef.current || !editorRef.current.state) { - return; - } - - const doc = editorRef.current.state.doc; - const { start, end } = highlightRange; - const from = doc.line(start.line).from + start.character - 1; - const to = doc.line(end.line).from + end.character - 1; - const selection = EditorSelection.range(from, to); - - editorRef.current.view?.dispatch({ - effects: [ - EditorView.scrollIntoView(selection, { y: "center" }), - ] - }); - // @note: we need to include `isEditorCreated` in the dependency array since - // a race-condition can happen if the `highlightRange` is resolved before the - // editor is created. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [highlightRange, isEditorCreated]); - - const theme = useCodeMirrorTheme(); - - return ( - - { - setIsEditorCreated(true); - }} - value={source} - extensions={extensions} - readOnly={true} - theme={theme} - > - {editorRef.current && editorRef.current.view && currentSelection && ( - - )} - - - ) -} - diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx new file mode 100644 index 00000000..14726b47 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { ResizablePanel } from "@/components/ui/resizable"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup"; +import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension"; +import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension"; +import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; +import { useKeymapExtension } from "@/hooks/useKeymapExtension"; +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { search } from "@codemirror/search"; +import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { EditorContextMenu } from "../../../components/editorContextMenu"; +import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation"; +import { useBrowseState } from "../../hooks/useBrowseState"; +import { rangeHighlightingExtension } from "./rangeHighlightingExtension"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface CodePreviewPanelProps { + path: string; + repoName: string; + revisionName: string; + source: string; + language: string; +} + +export const CodePreviewPanel = ({ + source, + language, + path, + repoName, + revisionName, +}: CodePreviewPanelProps) => { + const [editorRef, setEditorRef] = useState(null); + const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view); + const [currentSelection, setCurrentSelection] = useState(); + const keymapExtension = useKeymapExtension(editorRef?.view); + const hasCodeNavEntitlement = useHasEntitlement("code-nav"); + const { updateBrowseState } = useBrowseState(); + const { navigateToPath } = useBrowseNavigation(); + const captureEvent = useCaptureEvent(); + + const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM); + const highlightRange = useMemo((): BrowseHighlightRange | undefined => { + if (!highlightRangeQuery) { + return; + } + + // Highlight ranges can be formatted in two ways: + // 1. start_line,end_line (no column specified) + // 2. start_line:start_column,end_line:end_column (column specified) + const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/; + if (!rangeRegex.test(highlightRangeQuery)) { + return; + } + + const [start, end] = highlightRangeQuery.split(',').map((range) => { + if (range.includes(':')) { + return range.split(':').map((val) => parseInt(val, 10)); + } + // For line-only format, use column 1 for start and last column for end + const line = parseInt(range, 10); + return [line]; + }); + + if (start.length === 1 || end.length === 1) { + return { + start: { + lineNumber: start[0], + }, + end: { + lineNumber: end[0], + } + } + } else { + return { + start: { + lineNumber: start[0], + column: start[1], + }, + end: { + lineNumber: end[0], + column: end[1], + } + } + } + + }, [highlightRangeQuery]); + + const extensions = useMemo(() => { + return [ + languageExtension, + EditorView.lineWrapping, + keymapExtension, + search({ + top: true, + }), + EditorView.updateListener.of((update: ViewUpdate) => { + if (update.selectionSet) { + setCurrentSelection(update.state.selection.main); + } + }), + highlightRange ? rangeHighlightingExtension(highlightRange) : [], + hasCodeNavEntitlement ? symbolHoverTargetsExtension : [], + ]; + }, [ + keymapExtension, + languageExtension, + highlightRange, + hasCodeNavEntitlement, + ]); + + // Scroll the highlighted range into view. + useEffect(() => { + if (!highlightRange || !editorRef || !editorRef.state) { + return; + } + + const doc = editorRef.state.doc; + const { start, end } = highlightRange; + const selection = EditorSelection.range( + doc.line(start.lineNumber).from, + doc.line(end.lineNumber).from, + ); + + editorRef.view?.dispatch({ + effects: [ + EditorView.scrollIntoView(selection, { y: "center" }), + ] + }); + }, [editorRef, highlightRange]); + + const onFindReferences = useCallback((symbolName: string) => { + captureEvent('wa_browse_find_references_pressed', {}); + + updateBrowseState({ + selectedSymbolInfo: { + repoName, + symbolName, + revisionName, + language, + }, + isBottomPanelCollapsed: false, + activeExploreMenuTab: "references", + }) + }, [captureEvent, updateBrowseState, repoName, revisionName, language]); + + + // If we resolve multiple matches, instead of navigating to the first match, we should + // instead popup the bottom sheet with the list of matches. + const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { + captureEvent('wa_browse_goto_definition_pressed', {}); + + if (symbolDefinitions.length === 0) { + return; + } + + if (symbolDefinitions.length === 1) { + const symbolDefinition = symbolDefinitions[0]; + const { fileName, repoName } = symbolDefinition; + + navigateToPath({ + repoName, + revisionName, + path: fileName, + pathType: 'blob', + highlightRange: symbolDefinition.range, + }) + } else { + updateBrowseState({ + selectedSymbolInfo: { + symbolName, + repoName, + revisionName, + language, + }, + activeExploreMenuTab: "definitions", + isBottomPanelCollapsed: false, + }) + } + }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]); + + const theme = useCodeMirrorTheme(); + + return ( + + + + {editorRef && editorRef.view && currentSelection && ( + + )} + {editorRef && hasCodeNavEntitlement && ( + + )} + + + + + ) +} + diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts b/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts new file mode 100644 index 00000000..b5bba639 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts @@ -0,0 +1,39 @@ +'use client'; + +import { StateField, Range } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; +import { BrowseHighlightRange } from "../../hooks/useBrowseNavigation"; + +const markDecoration = Decoration.mark({ + class: "searchMatch-selected", +}); + +const lineDecoration = Decoration.line({ + attributes: { class: "lineHighlight" }, +}); + +export const rangeHighlightingExtension = (range: BrowseHighlightRange) => StateField.define({ + create(state) { + const { start, end } = range; + + if ('column' in start && 'column' in end) { + const from = state.doc.line(start.lineNumber).from + start.column - 1; + const to = state.doc.line(end.lineNumber).from + end.column - 1; + + return Decoration.set([ + markDecoration.range(from, to), + ]); + } else { + const decorations: Range[] = []; + for (let line = start.lineNumber; line <= end.lineNumber; line++) { + decorations.push(lineDecoration.range(state.doc.line(line).from)); + } + + return Decoration.set(decorations); + } + }, + update(deco, tr) { + return deco.map(tr.changes); + }, + provide: (field) => EditorView.decorations.from(field), +}); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 38ddedf1..12a290a7 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -2,15 +2,15 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { TopBar } from "@/app/[domain]/components/topBar"; import { Separator } from '@/components/ui/separator'; import { getFileSource } from '@/features/search/fileSourceApi'; -import { isServiceError } from "@/lib/utils"; +import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"; import { base64Decode } from "@/lib/utils"; -import { CodePreview } from "./codePreview"; import { ErrorCode } from "@/lib/errorCodes"; import { LuFileX2, LuBookX } from "react-icons/lu"; -import { getOrgFromDomain } from "@/data/org"; import { notFound } from "next/navigation"; import { ServiceErrorException } from "@/lib/serviceError"; import { getRepoInfoByName } from "@/actions"; +import { CodePreviewPanel } from "./components/codePreviewPanel"; +import Image from "next/image"; interface BrowsePageProps { params: { @@ -50,7 +50,18 @@ export default async function BrowsePage({ })(); const repoInfo = await getRepoInfoByName(repoName, params.domain); - if (isServiceError(repoInfo) && repoInfo.errorCode !== ErrorCode.NOT_FOUND) { + if (isServiceError(repoInfo)) { + if (repoInfo.errorCode === ErrorCode.NOT_FOUND) { + return ( +
+
+ + Repository not found +
+
+ ); + } + throw new ServiceErrorException(repoInfo); } @@ -63,70 +74,11 @@ export default async function BrowsePage({ ) } - return ( -
-
- - - {!isServiceError(repoInfo) && ( - <> -
- -
- - - )} -
- {isServiceError(repoInfo) ? ( -
-
- - Repository not found -
-
- ) : ( - - )} -
- ) -} - -interface CodePreviewWrapper { - path: string, - repoName: string, - revisionName: string, - domain: string, -} - -const CodePreviewWrapper = async ({ - path, - repoName, - revisionName, - domain, -}: CodePreviewWrapper) => { - // @todo: this will depend on `pathType`. const fileSourceResponse = await getFileSource({ fileName: path, repository: repoName, - branch: revisionName, - }, domain); + branch: revisionName ?? 'HEAD', + }, params.domain); if (isServiceError(fileSourceResponse)) { if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) { @@ -143,13 +95,57 @@ const CodePreviewWrapper = async ({ throw new ServiceErrorException(fileSourceResponse); } + const codeHostInfo = getCodeHostInfoForRepo({ + codeHostType: repoInfo.codeHostType, + name: repoInfo.name, + displayName: repoInfo.displayName, + webUrl: repoInfo.webUrl, + }); + return ( - + <> +
+ + +
+ + {(fileSourceResponse.webUrl && codeHostInfo) && ( + + {codeHostInfo.codeHostName} + Open in {codeHostInfo.codeHostName} + + )} +
+ +
+ + ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx new file mode 100644 index 00000000..9a4bb4b3 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { createContext, useCallback, useEffect, useState } from "react"; +import { BOTTOM_PANEL_MIN_SIZE } from "./components/bottomPanel"; + +export interface BrowseState { + selectedSymbolInfo?: { + symbolName: string; + repoName: string; + revisionName: string; + language: string; + } + isBottomPanelCollapsed: boolean; + activeExploreMenuTab: "references" | "definitions"; + bottomPanelSize: number; +} + +const defaultState: BrowseState = { + selectedSymbolInfo: undefined, + isBottomPanelCollapsed: true, + activeExploreMenuTab: "references", + bottomPanelSize: BOTTOM_PANEL_MIN_SIZE, +}; + +export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState"; + +export const BrowseStateContext = createContext<{ + state: BrowseState; + updateBrowseState: (state: Partial) => void; +}>({ + state: defaultState, + updateBrowseState: () => {}, +}); + +export const BrowseStateProvider = ({ children }: { children: React.ReactNode }) => { + const [state, setState] = useState(defaultState); + const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM); + + const onUpdateState = useCallback((state: Partial) => { + setState((prevState) => ({ + ...prevState, + ...state, + })); + }, []); + + useEffect(() => { + if (hydratedBrowseState) { + try { + const parsedState = JSON.parse(hydratedBrowseState) as Partial; + onUpdateState(parsedState); + } catch (error) { + console.error("Error parsing hydratedBrowseState", error); + } + + // Remove the query param + const url = new URL(window.location.href); + url.searchParams.delete(SET_BROWSE_STATE_QUERY_PARAM); + window.history.replaceState({}, '', url.toString()); + } + }, [hydratedBrowseState, onUpdateState]); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx new file mode 100644 index 00000000..86147ee7 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import { Button } from "@/components/ui/button"; +import { ResizablePanel } from "@/components/ui/resizable"; +import { Separator } from "@/components/ui/separator"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { useEffect, useRef } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { FaChevronDown } from "react-icons/fa"; +import { VscReferences, VscSymbolMisc } from "react-icons/vsc"; +import { ImperativePanelHandle } from "react-resizable-panels"; +import { useBrowseState } from "../hooks/useBrowseState"; +import { ExploreMenu } from "@/ee/features/codeNav/components/exploreMenu"; +import Link from "next/link"; +import { useDomain } from "@/hooks/useDomain"; +import { useRouter } from "next/navigation"; + +export const BOTTOM_PANEL_MIN_SIZE = 35; +export const BOTTOM_PANEL_MAX_SIZE = 65; +const CODE_NAV_DOCS_URL = "https://docs.sourcebot.dev/docs/search/code-navigation"; + +export const BottomPanel = () => { + const panelRef = useRef(null); + const hasCodeNavEntitlement = useHasEntitlement("code-nav"); + const domain = useDomain(); + const router = useRouter(); + + const { + state: { selectedSymbolInfo, isBottomPanelCollapsed, bottomPanelSize }, + updateBrowseState, + } = useBrowseState(); + + useEffect(() => { + if (isBottomPanelCollapsed) { + panelRef.current?.collapse(); + } else { + panelRef.current?.expand(); + } + }, [isBottomPanelCollapsed]); + + useHotkeys("shift+mod+e", (event) => { + event.preventDefault(); + updateBrowseState({ isBottomPanelCollapsed: !isBottomPanelCollapsed }); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Open Explore Panel", + }); + + return ( + <> +
+
+ +
+ + {!isBottomPanelCollapsed && ( + + )} +
+ + updateBrowseState({ isBottomPanelCollapsed: true })} + onExpand={() => updateBrowseState({ isBottomPanelCollapsed: false })} + onResize={(size) => { + if (!isBottomPanelCollapsed) { + updateBrowseState({ bottomPanelSize: size }); + } + }} + order={2} + id={"bottom-panel"} + > + {!hasCodeNavEntitlement ? ( +
+ +

+ Code navigation is not enabled for router.push(`/${domain}/settings/license`)}>your plan. +

+ + + Learn more + +
+ ) : !selectedSymbolInfo ? ( +
+ +

No symbol selected

+ + Learn more + +
+ ) : ( + + )} +
+ + ) +} + diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts new file mode 100644 index 00000000..83780153 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts @@ -0,0 +1,59 @@ +import { useRouter } from "next/navigation"; +import { useDomain } from "@/hooks/useDomain"; +import { useCallback } from "react"; +import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider"; + +export type BrowseHighlightRange = { + start: { lineNumber: number; column: number; }; + end: { lineNumber: number; column: number; }; +} | { + start: { lineNumber: number; }; + end: { lineNumber: number; }; +} + +export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange'; + +interface NavigateToPathOptions { + repoName: string; + revisionName?: string; + path: string; + pathType: 'blob' | 'tree'; + highlightRange?: BrowseHighlightRange; + setBrowseState?: Partial; +} + +export const useBrowseNavigation = () => { + const router = useRouter(); + const domain = useDomain(); + + const navigateToPath = useCallback(({ + repoName, + revisionName = 'HEAD', + path, + pathType, + highlightRange, + setBrowseState, + }: NavigateToPathOptions) => { + const params = new URLSearchParams(); + + if (highlightRange) { + const { start, end } = highlightRange; + + if ('column' in start && 'column' in end) { + params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`); + } else { + params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber},${end.lineNumber}`); + } + } + + if (setBrowseState) { + params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState)); + } + + router.push(`/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${path}?${params.toString()}`); + }, [domain, router]); + + return { + navigateToPath, + }; +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts new file mode 100644 index 00000000..5ff4924c --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useContext } from "react"; +import { BrowseStateContext } from "../browseStateProvider"; + +export const useBrowseState = () => { + const context = useContext(BrowseStateContext); + if (!context) { + throw new Error('useBrowseState must be used within a BrowseStateProvider'); + } + return context; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx new file mode 100644 index 00000000..4f23d9cc --- /dev/null +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -0,0 +1,26 @@ +import { ResizablePanelGroup } from "@/components/ui/resizable"; +import { BottomPanel } from "./components/bottomPanel"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { BrowseStateProvider } from "./browseStateProvider"; + +interface LayoutProps { + children: React.ReactNode; +} + +export default function Layout({ + children, +}: LayoutProps) { + return ( + +
+ + {children} + + + +
+
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx index 50560f5d..6bfe4baf 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx @@ -40,7 +40,7 @@ export const SecretCombobox = ({ const captureEvent = useCaptureEvent(); const { data: secrets, isPending, isError, refetch } = useQuery({ - queryKey: ["secrets"], + queryKey: ["secrets", domain], queryFn: () => unwrapServiceError(getSecrets(domain)), }); diff --git a/packages/web/src/app/[domain]/components/editorContextMenu.tsx b/packages/web/src/app/[domain]/components/editorContextMenu.tsx index d567198c..102f9f89 100644 --- a/packages/web/src/app/[domain]/components/editorContextMenu.tsx +++ b/packages/web/src/app/[domain]/components/editorContextMenu.tsx @@ -9,6 +9,7 @@ import { Link2Icon } from "@radix-ui/react-icons"; import { EditorView, SelectionRange } from "@uiw/react-codemirror"; import { useCallback, useEffect, useRef } from "react"; import { useDomain } from "@/hooks/useDomain"; +import { HIGHLIGHT_RANGE_QUERY_PARAM } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; interface ContextMenuProps { view: EditorView; @@ -107,7 +108,7 @@ export const EditorContextMenu = ({ const basePath = `${window.location.origin}/${domain}/browse`; const url = createPathWithQueryParams(`${basePath}/${repoName}@${revisionName}/-/blob/${path}`, - ['highlightRange', `${from?.line}:${from?.column},${to?.line}:${to?.column}`], + [HIGHLIGHT_RANGE_QUERY_PARAM, `${from?.line}:${from?.column},${to?.line}:${to?.column}`], ); navigator.clipboard.writeText(url); diff --git a/packages/web/src/app/[domain]/components/fileHeader.tsx b/packages/web/src/app/[domain]/components/fileHeader.tsx index 852e7c50..3eff5be6 100644 --- a/packages/web/src/app/[domain]/components/fileHeader.tsx +++ b/packages/web/src/app/[domain]/components/fileHeader.tsx @@ -1,9 +1,11 @@ +'use client'; import { getCodeHostInfoForRepo } from "@/lib/utils"; import { LaptopIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; +import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation"; interface FileHeaderProps { fileName: string; @@ -35,6 +37,8 @@ export const FileHeader = ({ webUrl: repo.webUrl, }); + const { navigateToPath } = useBrowseNavigation(); + return (
{info?.icon ? ( @@ -58,17 +62,11 @@ export const FileHeader = ({

- {/* hack since to make the @ symbol look more centered with the text */} - - @ - + @ {`${branchDisplayName}`}

)} @@ -76,7 +74,17 @@ export const FileHeader = ({
- + { + navigateToPath({ + repoName: repo.name, + path: fileName, + pathType: 'blob', + revisionName: branchDisplayName, + }); + }} + > {!fileNameHighlightRange ? fileName : ( diff --git a/packages/web/src/app/[domain]/components/keyboardShortcutHint.tsx b/packages/web/src/app/[domain]/components/keyboardShortcutHint.tsx deleted file mode 100644 index f93209f1..00000000 --- a/packages/web/src/app/[domain]/components/keyboardShortcutHint.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react' - -interface KeyboardShortcutHintProps { - shortcut: string - label?: string -} - -export function KeyboardShortcutHint({ shortcut, label }: KeyboardShortcutHintProps) { - return ( -
- - {shortcut} - -
- ) -} diff --git a/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx b/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx new file mode 100644 index 00000000..1cb01719 --- /dev/null +++ b/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx @@ -0,0 +1,276 @@ +import { Parser } from '@lezer/common' +import { LanguageDescription, StreamLanguage } from '@codemirror/language' +import { Highlighter, highlightTree } from '@lezer/highlight' +import { languages as builtinLanguages } from '@codemirror/language-data' +import { memo, useEffect, useMemo, useState } from 'react' +import { useCodeMirrorHighlighter } from '@/hooks/useCodeMirrorHighlighter' +import tailwind from '@/tailwind' +import { measure } from '@/lib/utils' +import { SourceRange } from '@/features/search/types' + +// Define a plain text language +const plainTextLanguage = StreamLanguage.define({ + token(stream) { + stream.next(); + return null; + } +}); + +interface LightweightCodeHighlighter { + language: string; + children: string; + /* 1-based highlight ranges */ + highlightRanges?: SourceRange[]; + lineNumbers?: boolean; + /* 1-based line number offset */ + lineNumbersOffset?: number; + renderWhitespace?: boolean; +} + +/** + * Lightweight code highlighter that uses the Lezer parser to highlight code. + * This is helpful in scenarios where we need to highlight a ton of code snippets + * (e.g., code nav, search results, etc)., but can't use the full-blown CodeMirror + * editor because of perf issues. + * + * Inspired by: https://github.com/craftzdog/react-codemirror-runmode + */ +export const LightweightCodeHighlighter = memo((props: LightweightCodeHighlighter) => { + const { + language, + children: code, + highlightRanges, + lineNumbers = false, + lineNumbersOffset = 1, + renderWhitespace = false, + } = props; + + const unhighlightedLines = useMemo(() => { + return code.trimEnd().split('\n'); + }, [code]); + + + const [highlightedLines, setHighlightedLines] = useState(null); + + const highlightStyle = useCodeMirrorHighlighter(); + + useEffect(() => { + measure(() => Promise.all( + unhighlightedLines + .map(async (line, index) => { + const lineNumber = index + lineNumbersOffset; + + // @todo: we will need to handle the case where a range spans multiple lines. + const ranges = highlightRanges?.filter(range => { + return range.start.lineNumber === lineNumber || range.end.lineNumber === lineNumber; + }).map(range => ({ + from: range.start.column - 1, + to: range.end.column - 1, + })); + + const snippets = await highlightCode( + language, + line, + highlightStyle, + ranges, + (text: string, style: string | null, from: number) => { + return ( + + {text} + + ) + } + ); + + return {snippets} + }) + ).then(highlightedLines => { + setHighlightedLines(highlightedLines); + }), 'highlightCode', /* outputLog = */ false); + }, [ + language, + code, + highlightRanges, + highlightStyle, + unhighlightedLines, + lineNumbersOffset + ]); + + const lineCount = (highlightedLines ?? unhighlightedLines).length + lineNumbersOffset; + const lineNumberDigits = String(lineCount).length; + const lineNumberWidth = `${lineNumberDigits + 2}ch`; // +2 for padding + + return ( +
+ {(highlightedLines ?? unhighlightedLines).map((line, index) => ( +
+ {lineNumbers && ( + + {index + lineNumbersOffset} + + )} + + {line} + +
+ ))} +
+ ) +}) + +LightweightCodeHighlighter.displayName = 'LightweightCodeHighlighter'; + +async function getCodeParser( + languageName: string, +): Promise { + if (languageName) { + const parser = await (async () => { + const found = LanguageDescription.matchLanguageName( + builtinLanguages, + languageName, + true + ); + + if (!found) { + return null; + } + + if (!found.support) { + await found.load(); + } + return found.support ? found.support.language.parser : null; + })(); + + if (parser) { + return parser; + } + } + return plainTextLanguage.parser; +} + +async function highlightCode( + languageName: string, + input: string, + highlighter: Highlighter, + highlightRanges: { from: number, to: number }[] = [], + callback: ( + text: string, + style: string | null, + from: number, + to: number + ) => Output, +): Promise { + const parser = await getCodeParser(languageName); + + /** + * Converts a range to a series of highlighted subranges. + */ + const convertRangeToHighlightedSubranges = ( + from: number, + to: number, + classes: string | null, + cb: (from: number, to: number, classes: string | null) => void, + ) => { + type HighlightRange = { + from: number, + to: number, + isHighlighted: boolean, + } + + const highlightClasses = classes ? `${classes} searchMatch-selected` : 'searchMatch-selected'; + + let currentRange: HighlightRange | null = null; + for (let i = from; i < to; i++) { + const isHighlighted = isIndexHighlighted(i, highlightRanges); + + if (currentRange) { + if (currentRange.isHighlighted === isHighlighted) { + currentRange.to = i + 1; + } else { + cb( + currentRange.from, + currentRange.to, + currentRange.isHighlighted ? highlightClasses : classes, + ) + + currentRange = { from: i, to: i + 1, isHighlighted }; + } + } else { + currentRange = { from: i, to: i + 1, isHighlighted }; + } + } + + if (currentRange) { + cb( + currentRange.from, + currentRange.to, + currentRange.isHighlighted ? highlightClasses : classes, + ) + } + } + + const tree = parser.parse(input) + const output: Array = []; + + let pos = 0; + highlightTree(tree, highlighter, (from, to, classes) => { + // `highlightTree` only calls this callback when at least one style/class + // is applied to the text (i.e., `classes` is not empty). This means that + // any unstyled regions will be skipped (e.g., whitespace, `=`. `;`. etc). + // This check ensures that we process these unstyled regions as well. + // @see: https://discuss.codemirror.net/t/static-highlighting-using-cm-v6/3420/2 + if (from > pos) { + convertRangeToHighlightedSubranges(pos, from, null, (from, to, classes) => { + output.push(callback(input.slice(from, to), classes, from, to)); + }) + } + + convertRangeToHighlightedSubranges(from, to, classes, (from, to, classes) => { + output.push(callback(input.slice(from, to), classes, from, to)); + }) + + pos = to; + }); + + // Process any remaining unstyled regions. + if (pos != tree.length) { + convertRangeToHighlightedSubranges(pos, tree.length, null, (from, to, classes) => { + output.push(callback(input.slice(from, to), classes, from, to)); + }) + } + return output; +} + +const isIndexHighlighted = (index: number, ranges: { from: number, to: number }[]) => { + return ranges.some(range => index >= range.from && index < range.to); +} diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx index ba760d6b..9aa6f5e3 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -14,6 +14,8 @@ import { TrialNavIndicator } from "./trialNavIndicator"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { env } from "@/env.mjs"; import { getSubscriptionInfo } from "@/ee/features/billing/actions"; +import { auth } from "@/auth"; +import WhatsNewIndicator from "./whatsNewIndicator"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; @@ -26,9 +28,11 @@ export const NavigationMenu = async ({ domain, }: NavigationMenuProps) => { const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null; + const session = await auth(); + const isAuthenticated = session?.user !== undefined; return ( -
+
- - - - Agents - - - @@ -73,23 +70,32 @@ export const NavigationMenu = async ({ - {env.SOURCEBOT_AUTH_ENABLED === 'true' && ( - - - - Connections - - - - )} - {env.SOURCEBOT_AUTH_ENABLED === 'true' && ( - - - - Settings - - - + {isAuthenticated && ( + <> + {env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined && ( + + + + Agents + + + + )} + + + + Connections + + + + + + + Settings + + + + )} @@ -100,6 +106,7 @@ export const NavigationMenu = async ({ +
{ "use server"; @@ -128,7 +135,7 @@ export const NavigationMenu = async ({ - +
diff --git a/packages/web/src/app/[domain]/components/pendingApproval.tsx b/packages/web/src/app/[domain]/components/pendingApproval.tsx new file mode 100644 index 00000000..1910a84a --- /dev/null +++ b/packages/web/src/app/[domain]/components/pendingApproval.tsx @@ -0,0 +1,60 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { HelpCircle } from "lucide-react" +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" +import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import { auth } from "@/auth" +import { ResubmitAccountRequestButton } from "./resubmitAccountRequestButton" + +interface PendingApprovalCardProps { + domain: string +} + +export const PendingApprovalCard = async ({ domain }: PendingApprovalCardProps) => { + const session = await auth() + const userId = session?.user?.id + + if (!userId) { + return null + } + + return ( +
+ + +
+ + + + Pending Approval + + Your request to join the organization is being reviewed + + + + +
+ +
+
+
+ +
+

Need help or have questions?

+ + Submit a support request + +
+
+
+
+
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx b/packages/web/src/app/[domain]/components/repositorySnapshot.tsx index c205b2da..eb18c945 100644 --- a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx +++ b/packages/web/src/app/[domain]/components/repositorySnapshot.tsx @@ -18,12 +18,10 @@ import { SymbolIcon } from "@radix-ui/react-icons"; import { RepositoryQuery } from "@/lib/types"; interface RepositorySnapshotProps { - authEnabled: boolean; repos: RepositoryQuery[]; } export function RepositorySnapshot({ - authEnabled, repos: initialRepos, }: RepositorySnapshotProps) { const domain = useDomain(); @@ -59,14 +57,14 @@ export function RepositorySnapshot({
) - // ... otherwise, show the empty state. + // ... otherwise, show the empty state. } else { return ( - + ) } } - + return (
@@ -83,7 +81,7 @@ export function RepositorySnapshot({ ) } -function EmptyRepoState({ domain, authEnabled }: { domain: string, authEnabled: boolean }) { +function EmptyRepoState() { return (
No repositories found @@ -91,23 +89,13 @@ function EmptyRepoState({ domain, authEnabled }: { domain: string, authEnabled:
- {authEnabled ? ( - <> - Create a{" "} - - connection - {" "} - to start indexing repositories - - ) : ( - <> - Create a {" "} - - configuration file - {" "} - to start indexing repositories - - )} + <> + Create a{" "} + + connection + {" "} + to start indexing repositories +
diff --git a/packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx b/packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx new file mode 100644 index 00000000..ec4df43e --- /dev/null +++ b/packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx @@ -0,0 +1,64 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Clock } from "lucide-react" +import { useState } from "react" +import { useToast } from "@/components/hooks/use-toast" +import { createAccountRequest } from "@/actions" +import { isServiceError } from "@/lib/utils" + +interface ResubmitButtonProps { + domain: string + userId: string +} + +export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonProps) { + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async () => { + setIsSubmitting(true) + const result = await createAccountRequest(userId, domain) + if (!isServiceError(result)) { + if (result.existingRequest) { + toast({ + title: "Request Already Submitted", + description: "Your request to join the organization has already been submitted. Please wait for it to be approved.", + variant: "default", + }) + } else { + toast({ + title: "Request Resubmitted", + description: "Your request to join the organization has been resubmitted.", + variant: "default", + }) + } + } else { + toast({ + title: "Failed to Resubmit", + description: `There was an error resubmitting your request. Reason: ${result.message}`, + variant: "destructive", + }) + } + + setIsSubmitting(false) + } + + return ( +
{ + e.preventDefault(); + handleSubmit(); + }}> + + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx index cb347d43..450ed74b 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx @@ -1,7 +1,6 @@ 'use client'; import { useClickListener } from "@/hooks/useClickListener"; -import { useTailwind } from "@/hooks/useTailwind"; import { SearchQueryParams } from "@/lib/types"; import { cn, createPathWithQueryParams } from "@/lib/utils"; import { @@ -43,7 +42,8 @@ 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"; -import { KeyboardShortcutHint } from "../keyboardShortcutHint"; +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import tailwind from "@/tailwind"; interface SearchBarProps { className?: string; @@ -95,7 +95,6 @@ export const SearchBar = ({ }: SearchBarProps) => { const router = useRouter(); const domain = useDomain(); - const tailwind = useTailwind(); const suggestionBoxRef = useRef(null); const editorRef = useRef(null); const [cursorPosition, setCursorPosition] = useState(0); @@ -161,7 +160,7 @@ export const SearchBar = ({ }, ], }); - }, [tailwind]); + }, []); const extensions = useMemo(() => { return [ @@ -267,7 +266,18 @@ export const SearchBar = ({ indentWithTab={false} autoFocus={autoFocus ?? false} /> - + + +
+ +
+
+ + Focus search bar + +
{ const domain = useDomain(); const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({ - queryKey: ["repoSuggestions"], + queryKey: ["repoSuggestions", domain], queryFn: () => getRepos(domain), select: (data): Suggestion[] => { return data.repos @@ -50,7 +50,7 @@ export const useSuggestionsData = ({ const isLoadingRepos = useMemo(() => suggestionMode === "repo" && _isLoadingRepos, [_isLoadingRepos, suggestionMode]); const { data: fileSuggestions, isLoading: _isLoadingFiles } = useQuery({ - queryKey: ["fileSuggestions", suggestionQuery], + queryKey: ["fileSuggestions", suggestionQuery, domain], queryFn: () => search({ query: `file:${suggestionQuery}`, matches: 15, @@ -70,7 +70,7 @@ export const useSuggestionsData = ({ const isLoadingFiles = useMemo(() => suggestionMode === "file" && _isLoadingFiles, [_isLoadingFiles, suggestionMode]); const { data: symbolSuggestions, isLoading: _isLoadingSymbols } = useQuery({ - queryKey: ["symbolSuggestions", suggestionQuery], + queryKey: ["symbolSuggestions", suggestionQuery, domain], queryFn: () => search({ query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`, matches: 15, @@ -100,7 +100,7 @@ export const useSuggestionsData = ({ const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]); const { data: searchContextSuggestions, isLoading: _isLoadingSearchContexts } = useQuery({ - queryKey: ["searchContexts"], + queryKey: ["searchContexts", domain], queryFn: () => getSearchContexts(domain), select: (data): Suggestion[] => { if (isServiceError(data)) { diff --git a/packages/web/src/app/[domain]/components/settingsDropdown.tsx b/packages/web/src/app/[domain]/components/settingsDropdown.tsx index 42925835..242dadad 100644 --- a/packages/web/src/app/[domain]/components/settingsDropdown.tsx +++ b/packages/web/src/app/[domain]/components/settingsDropdown.tsx @@ -3,6 +3,7 @@ import { CodeIcon, Laptop, + LogIn, LogOut, Moon, Settings, @@ -37,12 +38,10 @@ import { useDomain } from "@/hooks/useDomain"; interface SettingsDropdownProps { menuButtonClassName?: string; - displaySettingsOption: boolean; } export const SettingsDropdown = ({ menuButtonClassName, - displaySettingsOption, }: SettingsDropdownProps) => { const { theme: _theme, setTheme } = useTheme(); @@ -82,7 +81,7 @@ export const SettingsDropdown = ({ - {session?.user && ( + {session?.user ? (
@@ -107,9 +106,18 @@ export const SettingsDropdown = ({ Log out - + ) : ( + { + window.location.href = "/login"; + }} + > + + Sign in + )} + @@ -150,7 +158,7 @@ export const SettingsDropdown = ({ - {displaySettingsOption && ( + {session?.user && ( diff --git a/packages/web/src/app/[domain]/components/topBar.tsx b/packages/web/src/app/[domain]/components/topBar.tsx index 05146a91..351eb60b 100644 --- a/packages/web/src/app/[domain]/components/topBar.tsx +++ b/packages/web/src/app/[domain]/components/topBar.tsx @@ -40,7 +40,6 @@ export const TopBar = ({
) diff --git a/packages/web/src/app/[domain]/components/whatsNewIndicator.tsx b/packages/web/src/app/[domain]/components/whatsNewIndicator.tsx new file mode 100644 index 00000000..8259c03d --- /dev/null +++ b/packages/web/src/app/[domain]/components/whatsNewIndicator.tsx @@ -0,0 +1,179 @@ +"use client" + +import type React from "react" + +import { useState, useEffect } from "react" +import { HelpCircle, Mail, MailOpen } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { NewsItem } from "@/lib/types" +import { newsData } from "@/lib/newsData" + +interface WhatsNewProps { + newsItems?: NewsItem[] + autoMarkAsRead?: boolean +} + +const COOKIE_NAME = "whats-new-read-items" + +const getReadItems = (): string[] => { + if (typeof document === "undefined") return [] + + const cookies = document.cookie.split(';').map(cookie => cookie.trim()) + const targetCookie = cookies.find(cookie => cookie.startsWith(`${COOKIE_NAME}=`)) + + if (!targetCookie) return [] + + try { + const cookieValue = targetCookie.substring(`${COOKIE_NAME}=`.length) + return JSON.parse(decodeURIComponent(cookieValue)) + } catch (error) { + console.warn('Failed to parse whats-new cookie:', error) + return [] + } +} + +const setReadItems = (readItems: string[]) => { + if (typeof document === "undefined") return + + try { + const expires = new Date() + expires.setFullYear(expires.getFullYear() + 1) + const cookieValue = encodeURIComponent(JSON.stringify(readItems)) + + document.cookie = `${COOKIE_NAME}=${cookieValue}; expires=${expires.toUTCString()}; path=/; SameSite=Lax` + } catch (error) { + console.warn('Failed to set whats-new cookie:', error) + } +} + +export default function WhatsNewIndicator({ newsItems = newsData, autoMarkAsRead = true }: WhatsNewProps) { + const [isOpen, setIsOpen] = useState(false) + const [readItems, setReadItemsState] = useState([]) + const [isInitialized, setIsInitialized] = useState(false) + + useEffect(() => { + const items = getReadItems() + setReadItemsState(items) + setIsInitialized(true) + }, []) + + useEffect(() => { + if (isInitialized) { + setReadItems(readItems) + } + }, [readItems, isInitialized]) + + const newsItemsWithReadState = newsItems.map((item) => ({ + ...item, + read: readItems.includes(item.unique_id), + })) + + const unreadCount = newsItemsWithReadState.filter((item) => !item.read).length + + const markAsRead = (itemId: string) => { + setReadItemsState((prev) => { + if (!prev.includes(itemId)) { + return [...prev, itemId] + } + return prev + }) + } + + const markAllAsRead = () => { + const allIds = newsItems.map((item) => item.unique_id) + setReadItemsState(allIds) + } + + const handleNewsItemClick = (item: NewsItem) => { + window.open(item.url, "_blank", "noopener,noreferrer") + + if (autoMarkAsRead && !item.read) { + markAsRead(item.unique_id) + } + } + + return ( + + + + + +
+
+
+

{"What's New"}

+

+ {unreadCount > 0 ? `${unreadCount} unread update${unreadCount === 1 ? "" : "s"}` : "All caught up!"} +

+
+ {unreadCount > 0 && ( + + )} +
+
+
+ {newsItemsWithReadState.length === 0 ? ( +
No recent updates
+ ) : ( +
+ {newsItemsWithReadState.map((item, index) => ( +
+ {!item.read &&
} + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx index 4f43ba3e..a1e49637 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx @@ -180,7 +180,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => { )}
- + {isReposPending ? (
{Array.from({ length: 3 }).map((_, i) => ( diff --git a/packages/web/src/app/[domain]/connections/[id]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx index 04fc1512..e59dd3e0 100644 --- a/packages/web/src/app/[domain]/connections/[id]/page.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx @@ -22,6 +22,7 @@ import { isServiceError } from "@/lib/utils" import { notFound } from "next/navigation" import { OrgRole } from "@sourcebot/db" import { CodeHostType } from "@/lib/utils" +import { env } from "@/env.mjs" interface ConnectionManagementPageProps { params: { @@ -45,6 +46,7 @@ export default async function ConnectionManagementPage({ params, searchParams }: } const isOwner = membership.role === OrgRole.OWNER; + const isDisabled = !isOwner || env.CONFIG_PATH !== undefined; const currentTab = searchParams.tab || "overview"; return ( @@ -92,14 +94,14 @@ export default async function ConnectionManagementPage({ params, searchParams }: value="settings" className="flex flex-col gap-6" > - + - + ) diff --git a/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx index 043e7045..f6a97fa0 100644 --- a/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx +++ b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx @@ -11,26 +11,28 @@ import { OrgRole } from "@sourcebot/db" interface NewConnectionCardProps { className?: string role: OrgRole + configPathProvided: boolean } -export const NewConnectionCard = ({ className, role }: NewConnectionCardProps) => { +export const NewConnectionCard = ({ className, role, configPathProvided }: NewConnectionCardProps) => { const isOwner = role === OrgRole.OWNER + const isDisabled = !isOwner || configPathProvided return (
- {!isOwner && ( + {isDisabled && (
)} - -

+ +

Connect to a Code Host

@@ -41,42 +43,44 @@ export const NewConnectionCard = ({ className, role }: NewConnectionCardProps) = type="github" title="GitHub" subtitle="Cloud or Enterprise supported." - disabled={!isOwner} + disabled={isDisabled} />

- {!isOwner && ( + {isDisabled && (

- Only organization owners can manage connections. + {configPathProvided + ? "Connections are managed through the configuration file." + : "Only organization owners can manage connections."}

)}
diff --git a/packages/web/src/app/[domain]/connections/page.tsx b/packages/web/src/app/[domain]/connections/page.tsx index db9920d9..a76c38bd 100644 --- a/packages/web/src/app/[domain]/connections/page.tsx +++ b/packages/web/src/app/[domain]/connections/page.tsx @@ -5,6 +5,7 @@ import { getConnections, getOrgMembership } from "@/actions"; import { isServiceError } from "@/lib/utils"; import { notFound, ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@sourcebot/db"; +import { env } from "@/env.mjs"; export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) { const connections = await getConnections(domain); @@ -30,6 +31,7 @@ export default async function ConnectionsPage({ params: { domain } }: { params:
diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 8d712266..8b1122a3 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -11,9 +11,12 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/co import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; -import { env } from "@/env.mjs"; import { notFound, redirect } from "next/navigation"; import { getSubscriptionInfo } from "@/ee/features/billing/actions"; +import { PendingApprovalCard } from "./components/pendingApproval"; +import { hasEntitlement } from "@/features/entitlements/server"; +import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess"; +import { env } from "@/env.mjs"; interface LayoutProps { children: React.ReactNode, @@ -30,7 +33,8 @@ export default async function Layout({ return notFound(); } - if (env.SOURCEBOT_AUTH_ENABLED === 'true') { + const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain); + if (!publicAccessEnabled) { const session = await auth(); if (!session) { redirect('/login'); @@ -42,11 +46,25 @@ export default async function Layout({ orgId: org.id, userId: session.user.id } + }, + include: { + user: true } }); if (!membership) { - return notFound(); + const user = await prisma.user.findUnique({ + where: { + id: session.user.id + } + }); + + // TODO: Organization join requests are only supported in single-tenant mode + if (env.SOURCEBOT_TENANCY_MODE === "single" && user?.pendingApproval) { + return + } else { + return notFound(); + } } } diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx index 99ef681c..032d3433 100644 --- a/packages/web/src/app/[domain]/page.tsx +++ b/packages/web/src/app/[domain]/page.tsx @@ -9,7 +9,6 @@ import { Footer } from "@/app/components/footer"; import { SourcebotLogo } from "../components/sourcebotLogo"; import { RepositorySnapshot } from "./components/repositorySnapshot"; import { SyntaxReferenceGuideHint } from "./components/syntaxReferenceGuideHint"; -import { env } from '@/env.mjs'; import { getRepos } from "@/actions"; import { isServiceError } from "@/lib/utils"; @@ -39,7 +38,6 @@ export default async function Home({ params: { domain } }: { params: { domain: s />
diff --git a/packages/web/src/app/[domain]/repos/addRepoButton.tsx b/packages/web/src/app/[domain]/repos/addRepoButton.tsx index 0a72e085..739f4703 100644 --- a/packages/web/src/app/[domain]/repos/addRepoButton.tsx +++ b/packages/web/src/app/[domain]/repos/addRepoButton.tsx @@ -3,24 +3,28 @@ import { Button } from "@/components/ui/button" import { PlusCircle } from "lucide-react" import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogClose, - DialogFooter, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogClose, + DialogFooter, } from "@/components/ui/dialog" import { useState } from "react" import { ConnectionList } from "../connections/components/connectionList" import { useDomain } from "@/hooks/useDomain" import Link from "next/link"; +import { useSession } from "next-auth/react" -export function AddRepoButton({ isAddNewRepoButtonVisible }: { isAddNewRepoButtonVisible: boolean }) { - const [isOpen, setIsOpen] = useState(false) - const domain = useDomain() +export function AddRepoButton() { + const [isOpen, setIsOpen] = useState(false) + const domain = useDomain() + const { data: session } = useSession(); - return ( + return ( + <> + {session?.user && ( <> - + @@ -40,7 +44,7 @@ export function AddRepoButton({ isAddNewRepoButtonVisible }: { isAddNewRepoButto
- +
) + } + + ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/columns.tsx b/packages/web/src/app/[domain]/repos/columns.tsx index 934c7c54..756ca383 100644 --- a/packages/web/src/app/[domain]/repos/columns.tsx +++ b/packages/web/src/app/[domain]/repos/columns.tsx @@ -93,13 +93,13 @@ const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => { ) } -export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): ColumnDef[] => [ +export const columns = (domain: string): ColumnDef[] => [ { accessorKey: "name", header: () => (
Repository - {isAddNewRepoButtonVisible && } +
), cell: ({ row }) => { @@ -182,7 +182,7 @@ export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): Col @@ -196,6 +266,16 @@ export const CodePreview = ({ /> ) } + + {editorRef && hasCodeNavEntitlement && ( + + )} diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx index b3ea530b..537ac511 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx @@ -1,74 +1,79 @@ 'use client'; -import { fetchFileSource } from "@/app/api/(client)/client"; -import { base64Decode } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { CodePreview, CodePreviewFile } from "./codePreview"; +import { CodePreview } from "./codePreview"; import { SearchResultFile } from "@/features/search/types"; import { useDomain } from "@/hooks/useDomain"; import { SymbolIcon } from "@radix-ui/react-icons"; +import { SetStateAction, Dispatch, useMemo } from "react"; +import { getFileSource } from "@/features/search/fileSourceApi"; +import { base64Decode } from "@/lib/utils"; +import { unwrapServiceError } from "@/lib/utils"; interface CodePreviewPanelProps { - fileMatch?: SearchResultFile; - onClose: () => void; + previewedFile: SearchResultFile; selectedMatchIndex: number; - onSelectedMatchIndexChange: (index: number) => void; + onClose: () => void; + onSelectedMatchIndexChange: Dispatch>; } export const CodePreviewPanel = ({ - fileMatch, - onClose, + previewedFile, selectedMatchIndex, + onClose, onSelectedMatchIndexChange, }: CodePreviewPanelProps) => { const domain = useDomain(); - const { data: file, isLoading } = useQuery({ - queryKey: ["source", fileMatch?.fileName, fileMatch?.repository, fileMatch?.branches], - queryFn: async (): Promise => { - if (!fileMatch) { - return undefined; - } + // If there are multiple branches pointing to the same revision of this file, it doesn't + // matter which branch we use here, so use the first one. + const branch = useMemo(() => { + return previewedFile.branches && previewedFile.branches.length > 0 ? previewedFile.branches[0] : undefined; + }, [previewedFile]); - // If there are multiple branches pointing to the same revision of this file, it doesn't - // matter which branch we use here, so use the first one. - const branch = fileMatch.branches && fileMatch.branches.length > 0 ? fileMatch.branches[0] : undefined; - - return fetchFileSource({ - fileName: fileMatch.fileName.text, - repository: fileMatch.repository, + const { data: file, isLoading, isPending, isError } = useQuery({ + queryKey: ["source", previewedFile, branch, domain], + queryFn: () => unwrapServiceError( + getFileSource({ + fileName: previewedFile.fileName.text, + repository: previewedFile.repository, branch, }, domain) - .then(({ source }) => { - const decodedSource = base64Decode(source); + ), + select: (data) => { + const decodedSource = base64Decode(data.source); - return { - content: decodedSource, - filepath: fileMatch.fileName.text, - matches: fileMatch.chunks, - link: fileMatch.webUrl, - language: fileMatch.language, - revision: branch ?? "HEAD", - }; - }); - }, - enabled: fileMatch !== undefined, + return { + content: decodedSource, + filepath: previewedFile.fileName.text, + matches: previewedFile.chunks, + link: previewedFile.webUrl, + language: previewedFile.language, + revision: branch ?? "HEAD", + }; + } }); - if (isLoading) { + if (isLoading || isPending) { return

Loading...

} + if (isError) { + return ( +

Failed to load file source

+ ) + } + return ( ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx index 5020e516..c5081790 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx @@ -8,6 +8,8 @@ export type Entry = { displayName: string; count: number; isSelected: boolean; + isHidden: boolean; + isDisabled: boolean; Icon?: React.ReactNode; } @@ -22,6 +24,7 @@ export const Entry = ({ displayName, count, Icon, + isDisabled, }, onClicked, }: EntryProps) => { @@ -36,6 +39,7 @@ export const Entry = ({ { "hover:bg-gray-200 dark:hover:bg-gray-700": !isSelected, "bg-blue-200 dark:bg-blue-400": isSelected, + "opacity-50": isDisabled, } )} onClick={() => onClicked()} diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx index bb799587..231cda18 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx @@ -6,39 +6,49 @@ import { cn, getCodeHostInfoForRepo } from "@/lib/utils"; import { LaptopIcon } from "@radix-ui/react-icons"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { Entry } from "./entry"; import { Filter } from "./filter"; +import { LANGUAGES_QUERY_PARAM, REPOS_QUERY_PARAM, useFilteredMatches } from "./useFilterMatches"; +import { useGetSelectedFromQuery } from "./useGetSelectedFromQuery"; interface FilePanelProps { matches: SearchResultFile[]; - onFilterChanged: (filteredMatches: SearchResultFile[]) => void, repoInfo: Record; } -const LANGUAGES_QUERY_PARAM = "langs"; -const REPOS_QUERY_PARAM = "repos"; - +/** + * FilterPanel Component + * + * A bidirectional filtering component that allows users to filter search results by repository and language. + * The filtering is bidirectional, meaning: + * 1. When repositories are selected, the language filter will only show languages that exist in those repositories + * 2. When languages are selected, the repository filter will only show repositories that contain those languages + * + * This prevents users from selecting filter combinations that would yield no results. For example: + * - If Repository A only contains Python and JavaScript files, selecting it will only enable these languages + * - If Language Python is selected, only repositories containing Python files will be enabled + * + * @param matches - Array of search result files to filter + * @param repoInfo - Information about repositories including their display names and icons + */ export const FilterPanel = ({ matches, - onFilterChanged, repoInfo, }: FilePanelProps) => { const router = useRouter(); const searchParams = useSearchParams(); - // Helper to parse query params into sets - const getSelectedFromQuery = useCallback((param: string) => { - const value = searchParams.get(param); - return value ? new Set(value.split(',')) : new Set(); - }, [searchParams]); + const { getSelectedFromQuery } = useGetSelectedFromQuery(); + const matchesFilteredByRepository = useFilteredMatches(matches, 'repository'); + const matchesFilteredByLanguage = useFilteredMatches(matches, 'language'); const repos = useMemo(() => { const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); return aggregateMatches( "repository", matches, - ({ key, match }) => { + /* createEntry = */ ({ key: repository, match }) => { const repo: RepositoryInfo | undefined = repoInfo[match.repositoryId]; const info = repo ? getCodeHostInfoForRepo({ @@ -58,63 +68,72 @@ export const FilterPanel = ({ ); + const isSelected = selectedRepos.has(repository); + + // If the matches filtered by language don't contain this repository, then this entry is disabled + const isDisabled = !matchesFilteredByLanguage.some((match) => match.repository === repository); + const isHidden = isDisabled && !isSelected; + return { - key, - displayName: info?.displayName ?? key, + key: repository, + displayName: info?.displayName ?? repository, count: 0, - isSelected: selectedRepos.has(key), + isSelected, + isDisabled, + isHidden, Icon, }; + }, + /* shouldCount = */ ({ match }) => { + return matchesFilteredByLanguage.some((value) => value.language === match.language) } ) - }, [getSelectedFromQuery, matches, repoInfo]); + }, [getSelectedFromQuery, matches, repoInfo, matchesFilteredByLanguage]); const languages = useMemo(() => { const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); return aggregateMatches( "language", matches, - ({ key }) => { + /* createEntry = */ ({ key: language }) => { const Icon = ( - + ) + const isSelected = selectedLanguages.has(language); + + // If the matches filtered by repository don't contain this language, then this entry is disabled + const isDisabled = !matchesFilteredByRepository.some((match) => match.language === language); + const isHidden = isDisabled && !isSelected; + return { - key, - displayName: key, + key: language, + displayName: language, count: 0, - isSelected: selectedLanguages.has(key), + isSelected, + isDisabled, + isHidden, Icon: Icon, } satisfies Entry; + }, + /* shouldCount = */ ({ match }) => { + return matchesFilteredByRepository.some((value) => value.repository === match.repository) } ); - }, [getSelectedFromQuery, matches]); - - // Calls `onFilterChanged` with the filtered list of matches - // whenever the filter state changes. - useEffect(() => { - const selectedRepos = new Set(Object.keys(repos).filter((key) => repos[key].isSelected)); - const selectedLanguages = new Set(Object.keys(languages).filter((key) => languages[key].isSelected)); - - const filteredMatches = matches.filter((match) => - ( - (selectedRepos.size === 0 ? true : selectedRepos.has(match.repository)) && - (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.language)) - ) - ); - onFilterChanged(filteredMatches); + }, [getSelectedFromQuery, matches, matchesFilteredByRepository]); - }, [matches, repos, languages, onFilterChanged, searchParams, router]); + const visibleRepos = useMemo(() => Object.values(repos).filter((entry) => !entry.isHidden), [repos]); + const visibleLanguages = useMemo(() => Object.values(languages).filter((entry) => !entry.isHidden), [languages]); - const numRepos = useMemo(() => Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length, [repos]); - const numLanguages = useMemo(() => Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length, [languages]); + const numRepos = useMemo(() => visibleRepos.length > 100 ? '100+' : visibleRepos.length, [visibleRepos]); + const numLanguages = useMemo(() => visibleLanguages.length > 100 ? '100+' : visibleLanguages.length, [visibleLanguages]); return (
{ const newRepos = { ...repos }; newRepos[key].isSelected = !newRepos[key].isSelected; @@ -136,7 +155,7 @@ export const FilterPanel = ({ { const newLanguages = { ...languages }; newLanguages[key].isSelected = !newLanguages[key].isSelected; @@ -175,7 +194,8 @@ export const FilterPanel = ({ const aggregateMatches = ( propName: 'repository' | 'language', matches: SearchResultFile[], - createEntry: (props: { key: string, match: SearchResultFile }) => Entry + createEntry: (props: { key: string, match: SearchResultFile }) => Entry, + shouldCount: (props: { key: string, match: SearchResultFile }) => boolean, ) => { return matches .map((match) => ({ key: match[propName], match })) @@ -184,7 +204,11 @@ const aggregateMatches = ( if (!aggregation[key]) { aggregation[key] = createEntry({ key, match }); } - aggregation[key].count += 1; + + if (!aggregation[key].isDisabled && shouldCount({ key, match })) { + aggregation[key].count += 1; + } + return aggregation; }, {} as Record) } diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/useFilterMatches.ts b/packages/web/src/app/[domain]/search/components/filterPanel/useFilterMatches.ts new file mode 100644 index 00000000..5951d8ea --- /dev/null +++ b/packages/web/src/app/[domain]/search/components/filterPanel/useFilterMatches.ts @@ -0,0 +1,36 @@ +'use client'; + +import { SearchResultFile } from "@/features/search/types"; +import { useMemo } from "react"; +import { useGetSelectedFromQuery } from "./useGetSelectedFromQuery"; + +export const LANGUAGES_QUERY_PARAM = "langs"; +export const REPOS_QUERY_PARAM = "repos"; + + +export const useFilteredMatches = ( + matches: SearchResultFile[], + filterBy: 'repository' | 'language' | 'all' = 'all' +) => { + const { getSelectedFromQuery } = useGetSelectedFromQuery(); + + const filteredMatches = useMemo(() => { + const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); + const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); + + const isInRepoSet = (repo: string) => selectedRepos.size === 0 || selectedRepos.has(repo); + const isInLanguageSet = (language: string) => selectedLanguages.size === 0 || selectedLanguages.has(language); + + switch (filterBy) { + case 'repository': + return matches.filter((match) => isInRepoSet(match.repository)); + case 'language': + return matches.filter((match) => isInLanguageSet(match.language)); + case 'all': + return matches.filter((match) => isInRepoSet(match.repository) && isInLanguageSet(match.language)); + } + + }, [filterBy, getSelectedFromQuery, matches]); + + return filteredMatches; +} diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/useGetSelectedFromQuery.ts b/packages/web/src/app/[domain]/search/components/filterPanel/useGetSelectedFromQuery.ts new file mode 100644 index 00000000..5fefcb82 --- /dev/null +++ b/packages/web/src/app/[domain]/search/components/filterPanel/useGetSelectedFromQuery.ts @@ -0,0 +1,17 @@ +'use client'; + +import { useSearchParams } from "next/navigation"; +import { useCallback } from "react"; + +// Helper to parse query params into sets +export const useGetSelectedFromQuery = () => { + const searchParams = useSearchParams(); + const getSelectedFromQuery = useCallback((param: string): Set => { + const value = searchParams.get(param); + return value ? new Set(value.split(',')) : new Set(); + }, [searchParams]); + + return { + getSelectedFromQuery, + } +} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx deleted file mode 100644 index 47cb2678..00000000 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client'; - -import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage"; -import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension"; -import { SearchResultRange } from "@/features/search/types"; -import { EditorState, StateField, Transaction } from "@codemirror/state"; -import { Decoration, DecorationSet, EditorView, lineNumbers } from "@codemirror/view"; -import { useMemo, useRef } from "react"; -import { LightweightCodeMirror, CodeMirrorRef } from "./lightweightCodeMirror"; -import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; - -const markDecoration = Decoration.mark({ - class: "cm-searchMatch-selected" -}); - -interface CodePreviewProps { - content: string, - language: string, - ranges: SearchResultRange[], - lineOffset: number, -} - -export const CodePreview = ({ - content, - language, - ranges, - lineOffset, -}: CodePreviewProps) => { - const editorRef = useRef(null); - const theme = useCodeMirrorTheme(); - - const extensions = useMemo(() => { - const codemirrorExtension = getCodemirrorLanguage(language); - return [ - EditorView.editable.of(false), - theme, - lineNumbers(), - lineOffsetExtension(lineOffset), - codemirrorExtension ? codemirrorExtension : [], - StateField.define({ - create(editorState: EditorState) { - const document = editorState.doc; - - const decorations = ranges - .sort((a, b) => { - return a.start.byteOffset - b.start.byteOffset; - }) - .filter(({ start, end }) => { - const startLine = start.lineNumber - lineOffset; - const endLine = end.lineNumber - lineOffset; - - if ( - startLine < 1 || - endLine < 1 || - startLine > document.lines || - endLine > document.lines - ) { - return false; - } - return true; - }) - .map(({ start, end }) => { - const startLine = start.lineNumber - lineOffset; - const endLine = end.lineNumber - lineOffset; - - const from = document.line(startLine).from + start.column - 1; - const to = document.line(endLine).from + end.column - 1; - return markDecoration.range(from, to); - }) - .sort((a, b) => a.from - b.from); - - return Decoration.set(decorations); - }, - update(highlights: DecorationSet, _transaction: Transaction) { - return highlights; - }, - provide: (field) => EditorView.decorations.from(field), - }), - ] - }, [language, lineOffset, ranges, theme]); - - return ( - - ) - -} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx index aaefe1a6..5146c6fe 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx @@ -1,26 +1,34 @@ 'use client'; -import { useMemo } from "react"; -import { CodePreview } from "./codePreview"; +import { useCallback, useMemo } from "react"; import { SearchResultFile, SearchResultChunk } from "@/features/search/types"; import { base64Decode } from "@/lib/utils"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; interface FileMatchProps { match: SearchResultChunk; file: SearchResultFile; - onOpen: () => void; + onOpen: (startLineNumber: number, endLineNumber: number, isCtrlKeyPressed: boolean) => void; } export const FileMatch = ({ match, file, - onOpen, + onOpen: _onOpen, }: FileMatchProps) => { + const content = useMemo(() => { return base64Decode(match.content); }, [match.content]); + const onOpen = useCallback((isCtrlKeyPressed: boolean) => { + const startLineNumber = match.contentStart.lineNumber; + const endLineNumber = content.trimEnd().split('\n').length + startLineNumber - 1; + + _onOpen(startLineNumber, endLineNumber, isCtrlKeyPressed); + }, [content, match.contentStart.lineNumber, _onOpen]); + // If it's just the title, don't show a code preview if (match.matchRanges.length === 0) { return null; @@ -29,21 +37,28 @@ export const FileMatch = ({ return (
{ if (e.key !== "Enter") { return; } - onOpen(); + + onOpen(e.metaKey || e.ctrlKey); + }} + onClick={(e) => { + onOpen(e.metaKey || e.ctrlKey); }} - onClick={onOpen} + title="open file: click, open file preview: cmd/ctrl + click" > - + highlightRanges={match.matchRanges} + lineNumbers={true} + lineNumbersOffset={match.contentStart.lineNumber} + renderWhitespace={true} + > + {content} +
); } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx index 813fe10a..c179aacc 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -3,16 +3,17 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { Separator } from "@/components/ui/separator"; import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { FileMatch } from "./fileMatch"; import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; +import { Button } from "@/components/ui/button"; +import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; export const MAX_MATCHES_TO_PREVIEW = 3; interface FileMatchContainerProps { file: SearchResultFile; - onOpenFile: () => void; - onMatchIndexChanged: (matchIndex: number) => void; + onOpenFilePreview: (matchIndex?: number) => void; showAllMatches: boolean; onShowAllMatchesButtonClicked: () => void; isBranchFilteringEnabled: boolean; @@ -22,18 +23,17 @@ interface FileMatchContainerProps { export const FileMatchContainer = ({ file, - onOpenFile, - onMatchIndexChanged, + onOpenFilePreview, showAllMatches, onShowAllMatchesButtonClicked, isBranchFilteringEnabled, repoInfo, yOffset, }: FileMatchContainerProps) => { - const matchCount = useMemo(() => { return file.chunks.length; }, [file]); + const { navigateToPath } = useBrowseNavigation(); const matches = useMemo(() => { const sortedMatches = file.chunks.sort((a, b) => { @@ -63,14 +63,6 @@ export const FileMatchContainer = ({ return matchCount > MAX_MATCHES_TO_PREVIEW; }, [matchCount]); - const onOpenMatch = useCallback((index: number) => { - const matchIndex = matches.slice(0, index).reduce((acc, match) => { - return acc + match.matchRanges.length; - }, 0); - onOpenFile(); - onMatchIndexChanged(matchIndex); - }, [matches, onMatchIndexChanged, onOpenFile]); - const branches = useMemo(() => { if (!file.branches) { return []; @@ -91,18 +83,14 @@ export const FileMatchContainer = ({ return repoInfo[file.repositoryId]; }, [repoInfo, file.repositoryId]); - return (
{/* Title */}
{ - onOpenFile(); - }} > +
{/* Matches */} @@ -126,8 +123,28 @@ export const FileMatchContainer = ({ { - onOpenMatch(index); + onOpen={(startLineNumber, endLineNumber, isCtrlKeyPressed) => { + if (isCtrlKeyPressed) { + const matchIndex = matches.slice(0, index).reduce((acc, match) => { + return acc + match.matchRanges.length; + }, 0); + onOpenFilePreview(matchIndex); + } else { + navigateToPath({ + repoName: file.repository, + revisionName: file.branches?.[0] ?? 'HEAD', + path: file.fileName.text, + pathType: 'blob', + highlightRange: { + start: { + lineNumber: startLineNumber, + }, + end: { + lineNumber: endLineNumber, + } + } + }); + } }} /> {(index !== matches.length - 1 || isMoreContentButtonVisible) && ( @@ -140,7 +157,7 @@ export const FileMatchContainer = ({ {isMoreContentButtonVisible && (
{ if (e.key !== "Enter") { return; @@ -150,7 +167,7 @@ export const FileMatchContainer = ({ onClick={onShowAllMatchesButtonClicked} >

{showAllMatches ? : } {showAllMatches ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx index 88757c56..61e41332 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx @@ -2,13 +2,13 @@ import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDebounce, usePrevious } from "@uidotdev/usehooks"; interface SearchResultsPanelProps { fileMatches: SearchResultFile[]; - onOpenFileMatch: (fileMatch: SearchResultFile) => void; - onMatchIndexChanged: (matchIndex: number) => void; + onOpenFilePreview: (fileMatch: SearchResultFile, matchIndex?: number) => void; isLoadMoreButtonVisible: boolean; onLoadMoreButtonClicked: () => void; isBranchFilteringEnabled: boolean; @@ -19,18 +19,33 @@ const ESTIMATED_LINE_HEIGHT_PX = 20; const ESTIMATED_NUMBER_OF_LINES_PER_CODE_CELL = 10; const ESTIMATED_MATCH_CONTAINER_HEIGHT_PX = 30; +type ScrollHistoryState = { + scrollOffset?: number; + measurementsCache?: VirtualItem[]; + showAllMatchesStates?: boolean[]; +} + export const SearchResultsPanel = ({ fileMatches, - onOpenFileMatch, - onMatchIndexChanged, + onOpenFilePreview, isLoadMoreButtonVisible, onLoadMoreButtonClicked, isBranchFilteringEnabled, repoInfo, }: SearchResultsPanelProps) => { const parentRef = useRef(null); - const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false)); - const [lastShowAllMatchesButtonClickIndex, setLastShowAllMatchesButtonClickIndex] = useState(-1); + + // Restore the scroll offset, measurements cache, and other state from the history + // state. This enables us to restore the scroll offset when the user navigates back + // to the page. + // @see: https://github.com/TanStack/virtual/issues/378#issuecomment-2173670081 + const { + scrollOffset: restoreOffset, + measurementsCache: restoreMeasurementsCache, + showAllMatchesStates: restoreShowAllMatchesStates, + } = history.state as ScrollHistoryState; + + const [showAllMatchesStates, setShowAllMatchesStates] = useState(restoreShowAllMatchesStates || Array(fileMatches.length).fill(false)); const virtualizer = useVirtualizer({ count: fileMatches.length, @@ -51,60 +66,55 @@ export const SearchResultsPanel = ({ return estimatedSize; }, - measureElement: (element, _entry, instance) => { - // @note : Stutters were appearing when scrolling upwards. The workaround is - // to use the cached height of the element when scrolling up. - // @see : https://github.com/TanStack/virtual/issues/659 - const isCacheDirty = element.hasAttribute("data-cache-dirty"); - element.removeAttribute("data-cache-dirty"); - const direction = instance.scrollDirection; - if (direction === "forward" || direction === null || isCacheDirty) { - return element.scrollHeight; - } else { - const indexKey = Number(element.getAttribute("data-index")); - // Unfortunately, the cache is a private property, so we need to - // hush the TS compiler. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const cacheMeasurement = instance.itemSizeCache.get(indexKey); - return cacheMeasurement; - } - }, + initialOffset: restoreOffset, + initialMeasurementsCache: restoreMeasurementsCache, enabled: true, overscan: 10, debug: false, }); - const onShowAllMatchesButtonClicked = useCallback((index: number) => { - const states = [...showAllMatchesStates]; - states[index] = !states[index]; - setShowAllMatchesStates(states); - setLastShowAllMatchesButtonClickIndex(index); - }, [showAllMatchesStates]); - - // After the "show N more/less matches" button is clicked, the FileMatchContainer's - // size can change considerably. In cases where N > 3 or 4 cells when collapsing, - // a visual artifact can appear where there is a large gap between the now collapsed - // container and the next container. This is because the container's height was not - // re-calculated. To get arround this, we force a re-measure of the element AFTER - // it was re-rendered (hence the useLayoutEffect). - useLayoutEffect(() => { - if (lastShowAllMatchesButtonClickIndex < 0) { + // When the number of file matches changes, we need to reset our scroll state. + const prevFileMatches = usePrevious(fileMatches); + useEffect(() => { + if (!prevFileMatches) { return; } - const element = virtualizer.elementsCache.get(lastShowAllMatchesButtonClickIndex); - element?.setAttribute('data-cache-dirty', 'true'); - virtualizer.measureElement(element); - - setLastShowAllMatchesButtonClickIndex(-1); - }, [lastShowAllMatchesButtonClickIndex, virtualizer]); + if (prevFileMatches.length !== fileMatches.length) { + setShowAllMatchesStates(Array(fileMatches.length).fill(false)); + virtualizer.scrollToIndex(0); + } + }, [fileMatches.length, prevFileMatches, virtualizer]); - // Reset some state when the file matches change. + // Save the scroll state to the history stack. + const debouncedScrollOffset = useDebounce(virtualizer.scrollOffset, 100); useEffect(() => { - setShowAllMatchesStates(Array(fileMatches.length).fill(false)); - virtualizer.scrollToIndex(0); - }, [fileMatches, virtualizer]); + history.replaceState( + { + scrollOffset: debouncedScrollOffset ?? undefined, + measurementsCache: virtualizer.measurementsCache, + showAllMatchesStates, + } satisfies ScrollHistoryState, + '', + window.location.href + ); + }, [debouncedScrollOffset, virtualizer.measurementsCache, showAllMatchesStates]); + + const onShowAllMatchesButtonClicked = useCallback((index: number) => { + const states = [...showAllMatchesStates]; + const wasShown = states[index]; + states[index] = !wasShown; + setShowAllMatchesStates(states); + + // When collapsing, scroll to the top of the file match container. This ensures + // that the focused "show fewer matches" button is visible. + if (wasShown) { + virtualizer.scrollToIndex(index, { + align: 'start' + }); + } + }, [showAllMatchesStates, virtualizer]); + return (

{ - onOpenFileMatch(file); - }} - onMatchIndexChanged={(matchIndex) => { - onMatchIndexChanged(matchIndex); + onOpenFilePreview={(matchIndex) => { + onOpenFilePreview(file, matchIndex); }} showAllMatches={showAllMatchesStates[virtualRow.index]} onShowAllMatchesButtonClicked={() => { diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx deleted file mode 100644 index f6d3227e..00000000 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; - -import { EditorState, Extension, StateEffect } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; - -interface CodeMirrorProps { - value?: string; - extensions?: Extension[]; - className?: string; -} - -export interface CodeMirrorRef { - editor: HTMLDivElement | null; - state?: EditorState; - view?: EditorView; -} - -/** - * This component provides a lightweight CodeMirror component that has been optimized to - * render quickly in the search results panel. Why not use react-codemirror? For whatever reason, - * react-codemirror issues many StateEffects when first rendering, causing a stuttery scroll - * experience as new cells load. This component is a workaround for that issue and provides - * a minimal react wrapper around CodeMirror that avoids this issue. - */ -const LightweightCodeMirror = forwardRef(({ - value, - extensions, - className, -}, ref) => { - const editor = useRef(null); - const viewRef = useRef(); - const stateRef = useRef(); - - useImperativeHandle(ref, () => ({ - editor: editor.current, - state: stateRef.current, - view: viewRef.current, - }), []); - - useEffect(() => { - if (!editor.current) { - return; - } - - const state = EditorState.create({ - extensions: [], /* extensions are explicitly left out here */ - doc: value, - }); - stateRef.current = state; - - const view = new EditorView({ - state, - parent: editor.current, - }); - viewRef.current = view; - - return () => { - view.destroy(); - viewRef.current = undefined; - stateRef.current = undefined; - } - }, [value]); - - useEffect(() => { - if (viewRef.current) { - viewRef.current.dispatch({ effects: StateEffect.reconfigure.of(extensions ?? []) }); - } - }, [extensions]); - - return ( -
- ) -}); - -LightweightCodeMirror.displayName = "LightweightCodeMirror"; - -export { LightweightCodeMirror }; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index ac793ff8..e307d74c 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { - ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; @@ -15,7 +14,6 @@ import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons"; 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 { search } from "../../api/(client)/client"; import { TopBar } from "../components/topBar"; import { CodePreviewPanel } from "./components/codePreviewPanel"; @@ -24,8 +22,17 @@ import { SearchResultsPanel } from "./components/searchResultsPanel"; import { useDomain } from "@/hooks/useDomain"; import { useToast } from "@/components/hooks/use-toast"; import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { useFilteredMatches } from "./components/filterPanel/useFilterMatches"; +import { Button } from "@/components/ui/button"; +import { ImperativePanelHandle } from "react-resizable-panels"; +import { FilterIcon } from "lucide-react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useLocalStorage } from "@uidotdev/usehooks"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; -const DEFAULT_MATCH_COUNT = 10000; +const DEFAULT_MAX_MATCH_COUNT = 10000; export default function SearchPage() { // We need a suspense boundary here since we are accessing query params @@ -41,18 +48,20 @@ export default function SearchPage() { const SearchPageInternal = () => { const router = useRouter(); const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? ""; - const _matches = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MATCH_COUNT}`); - const matches = isNaN(_matches) ? DEFAULT_MATCH_COUNT : _matches; const { setSearchHistory } = useSearchHistory(); const captureEvent = useCaptureEvent(); const domain = useDomain(); const { toast } = useToast(); + // Encodes the number of matches to return in the search response. + const _maxMatchCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MAX_MATCH_COUNT}`); + const maxMatchCount = isNaN(_maxMatchCount) ? DEFAULT_MAX_MATCH_COUNT : _maxMatchCount; + const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({ - queryKey: ["search", searchQuery, matches], + queryKey: ["search", searchQuery, maxMatchCount], queryFn: () => measure(() => unwrapServiceError(search({ query: searchQuery, - matches, + matches: maxMatchCount, contextLines: 3, whole: false, }, domain)), "client.search"), @@ -63,6 +72,7 @@ const SearchPageInternal = () => { enabled: searchQuery.length > 0, refetchOnWindowFocus: false, retry: false, + staleTime: Infinity, }); useEffect(() => { @@ -122,7 +132,7 @@ const SearchPageInternal = () => { }); }, [captureEvent, searchQuery, searchResponse]); - const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo } = useMemo(() => { + const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo, matchCount } = useMemo(() => { if (!searchResponse) { return { fileMatches: [], @@ -130,6 +140,7 @@ const SearchPageInternal = () => { totalMatchCount: 0, isBranchFilteringEnabled: false, repositoryInfo: {}, + matchCount: 0, }; } @@ -142,32 +153,21 @@ const SearchPageInternal = () => { acc[repo.id] = repo; return acc; }, {} as Record), + matchCount: searchResponse.stats.matchCount, } }, [searchResponse]); const isMoreResultsButtonVisible = useMemo(() => { - return totalMatchCount > matches; - }, [totalMatchCount, matches]); - - const numMatches = useMemo(() => { - // Accumualtes the number of matches across all files - return fileMatches.reduce( - (acc, file) => - acc + file.chunks.reduce( - (acc, chunk) => acc + chunk.matchRanges.length, - 0, - ), - 0, - ); - }, [fileMatches]); + return totalMatchCount > maxMatchCount; + }, [totalMatchCount, maxMatchCount]); const onLoadMoreResults = useCallback(() => { const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, searchQuery], - [SearchQueryParams.matches, `${matches * 2}`], + [SearchQueryParams.matches, `${maxMatchCount * 2}`], ) router.push(url); - }, [matches, router, searchQuery, domain]); + }, [maxMatchCount, router, searchQuery, domain]); return (
@@ -193,7 +193,7 @@ const SearchPageInternal = () => { isBranchFilteringEnabled={isBranchFilteringEnabled} repoInfo={repositoryInfo} searchDurationMs={searchDurationMs} - numMatches={numMatches} + numMatches={matchCount} /> )}
@@ -219,22 +219,24 @@ const PanelGroup = ({ searchDurationMs, numMatches, }: PanelGroupProps) => { + const [previewedFile, setPreviewedFile] = useState(undefined); + const filteredFileMatches = useFilteredMatches(fileMatches); + const filterPanelRef = useRef(null); const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); - const [selectedFile, setSelectedFile] = useState(undefined); - const [filteredFileMatches, setFilteredFileMatches] = useState(fileMatches); - const codePreviewPanelRef = useRef(null); - useEffect(() => { - if (selectedFile) { - codePreviewPanelRef.current?.expand(); + const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false); + + useHotkeys("mod+b", () => { + if (isFilterPanelCollapsed) { + filterPanelRef.current?.expand(); } else { - codePreviewPanelRef.current?.collapse(); + filterPanelRef.current?.collapse(); } - }, [selectedFile]); - - const onFilterChanged = useCallback((matches: SearchResultFile[]) => { - setFilteredFileMatches(matches); - }, []); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Toggle filter panel", + }); return ( {/* ~~ Filter panel ~~ */} setIsFilterPanelCollapsed(true)} + onExpand={() => setIsFilterPanelCollapsed(false)} > - + {isFilterPanelCollapsed && ( +
+ + + + + + + + Open filter panel + + +
+ )} + {/* ~~ Search results ~~ */} 0 ? ( { - setSelectedFile(fileMatch); - }} - onMatchIndexChanged={(matchIndex) => { - setSelectedMatchIndex(matchIndex); + onOpenFilePreview={(fileMatch, matchIndex) => { + setSelectedMatchIndex(matchIndex ?? 0); + setPreviewedFile(fileMatch); }} isLoadMoreButtonVisible={!!isMoreResultsButtonVisible} onLoadMoreButtonClicked={onLoadMoreResults} @@ -304,25 +329,27 @@ const PanelGroup = ({
)} - - {/* ~~ Code preview ~~ */} - - setSelectedFile(undefined)} - selectedMatchIndex={selectedMatchIndex} - onSelectedMatchIndexChange={setSelectedMatchIndex} - /> - + {previewedFile && ( + <> + + {/* ~~ Code preview ~~ */} + setPreviewedFile(undefined)} + > + setPreviewedFile(undefined)} + selectedMatchIndex={selectedMatchIndex} + onSelectedMatchIndexChange={setSelectedMatchIndex} + /> + + + )} ) } diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx index d028ab0e..91a2fcfb 100644 --- a/packages/web/src/app/[domain]/settings/(general)/page.tsx +++ b/packages/web/src/app/[domain]/settings/(general)/page.tsx @@ -6,6 +6,7 @@ import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard"; import { ServiceErrorException } from "@/lib/serviceError"; import { ErrorCode } from "@/lib/errorCodes"; import { headers } from "next/headers"; + interface GeneralSettingsPageProps { params: { domain: string; diff --git a/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx b/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx new file mode 100644 index 00000000..a32bfabb --- /dev/null +++ b/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx @@ -0,0 +1,160 @@ +"use client" + +import { ColumnDef } from "@tanstack/react-table" +import { ArrowUpDown, Key, Trash2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { deleteApiKey } from "@/actions" +import { useParams } from "next/navigation" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { useState } from "react" +import { useToast } from "@/components/hooks/use-toast" + +export type ApiKeyColumnInfo = { + name: string + createdAt: string + lastUsedAt: string | null +} + +// Component for the actions cell to properly use React hooks +function ApiKeyActions({ apiKey }: { apiKey: ApiKeyColumnInfo }) { + const params = useParams<{ domain: string }>() + const [isPending, setIsPending] = useState(false) + const { toast } = useToast() + + const handleDelete = async () => { + setIsPending(true) + try { + await deleteApiKey(apiKey.name, params.domain) + window.location.reload() + } catch (error) { + console.error("Failed to delete API key", error) + toast({ + title: "Failed to Delete API Key", + description: `There was an error deleting the API key: ${error}`, + variant: "destructive", + }) + } finally { + setIsPending(false) + } + } + + return ( +
+ + + + + + + Delete API Key + + Are you sure you want to delete the API key {apiKey.name}? This action cannot be undone. + + + + Cancel + + {isPending ? "Deleting..." : "Delete"} + + + + +
+ ) +} + +export const columns = (): ColumnDef[] => [ + { + accessorKey: "name", + header: () =>
Name
, + cell: ({ row }) => { + const name = row.original.name + return ( +
+ + {name} +
+ ) + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( +
+ +
+ ), + cell: ({ row }) => { + if (!row.original.createdAt) { + return
+ } + const date = new Date(row.original.createdAt) + return ( +
+ {date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +
+ ) + }, + }, + { + accessorKey: "lastUsedAt", + header: ({ column }) => ( +
+ +
+ ), + cell: ({ row }) => { + if (!row.original.lastUsedAt) { + return
Never
+ } + const date = new Date(row.original.lastUsedAt) + return ( +
+ {date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +
+ ) + }, + }, + { + id: "actions", + cell: ({ row }) => + } +] \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/apiKeys/page.tsx b/packages/web/src/app/[domain]/settings/apiKeys/page.tsx new file mode 100644 index 00000000..9940beb9 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/apiKeys/page.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { createApiKey, getUserApiKeys } from "@/actions"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { isServiceError } from "@/lib/utils"; +import { Copy, Check, AlertTriangle, Loader2, Plus } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDomain } from "@/hooks/useDomain"; +import { useToast } from "@/components/hooks/use-toast"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { DataTable } from "@/components/ui/data-table"; +import { columns, ApiKeyColumnInfo } from "./columns"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function ApiKeysPage() { + const domain = useDomain(); + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + + const [apiKeys, setApiKeys] = useState<{ name: string; createdAt: Date; lastUsedAt: Date | null }[]>([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [isCreatingKey, setIsCreatingKey] = useState(false); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); + const [error, setError] = useState(null); + + const loadApiKeys = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const keys = await getUserApiKeys(domain); + if (isServiceError(keys)) { + setError("Failed to load API keys"); + toast({ + title: "Error", + description: "Failed to load API keys", + variant: "destructive", + }); + return; + } + setApiKeys(keys); + } catch (error) { + console.error(error); + setError("Failed to load API keys"); + toast({ + title: "Error", + description: "Failed to load API keys", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }, [domain, toast]); + + useEffect(() => { + loadApiKeys(); + }, [loadApiKeys]); + + const handleCreateApiKey = async () => { + if (!newKeyName.trim()) { + toast({ + title: "Error", + description: "API key name cannot be empty", + variant: "destructive", + }); + return; + } + + setIsCreatingKey(true); + try { + const result = await createApiKey(newKeyName.trim(), domain); + if (isServiceError(result)) { + toast({ + title: "Error", + description: `Failed to create API key: ${result.message}`, + variant: "destructive", + }); + captureEvent('wa_api_key_creation_fail', {}); + + return; + } + + setNewlyCreatedKey(result.key); + await loadApiKeys(); + captureEvent('wa_api_key_created', {}); + } catch (error) { + console.error(error); + toast({ + title: "Error", + description: `Failed to create API key: ${error}`, + variant: "destructive", + }); + captureEvent('wa_api_key_creation_fail', {}); + } finally { + setIsCreatingKey(false); + } + }; + + const handleCopyApiKey = () => { + if (!newlyCreatedKey) return; + + navigator.clipboard.writeText(newlyCreatedKey) + .then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }) + .catch(() => { + toast({ + title: "Error", + description: "Failed to copy API key to clipboard", + variant: "destructive", + }); + }); + }; + + const handleCloseDialog = () => { + setIsCreateDialogOpen(false); + setNewKeyName(""); + setNewlyCreatedKey(null); + setCopySuccess(false); + }; + + const tableData = useMemo(() => { + if (isLoading) return Array(4).fill(null).map(() => ({ + name: "", + createdAt: "", + lastUsedAt: null, + })); + + if (!apiKeys) return []; + + return apiKeys.map((key): ApiKeyColumnInfo => ({ + name: key.name, + createdAt: key.createdAt.toISOString(), + lastUsedAt: key.lastUsedAt?.toISOString() ?? null, + })).sort((a, b) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + }, [apiKeys, isLoading]); + + const tableColumns = useMemo(() => { + if (isLoading) { + return columns().map((column) => { + if ('accessorKey' in column && column.accessorKey === "name") { + return { + ...column, + cell: () => ( +
+ {/* Icon skeleton */} + {/* Name skeleton */} +
+ ), + } + } + + return { + ...column, + cell: () => , + } + }) + } + + return columns(); + }, [isLoading]); + + if (error) { + return
Error loading API keys
; + } + + return ( +
+
+
+

API Keys

+

+ Create and manage API keys for programmatic access to Sourcebot. All API keys are scoped to the user who created them. +

+
+ + + + + + + + {newlyCreatedKey ? 'Your New API Key' : 'Create API Key'} + + + {newlyCreatedKey ? ( +
+
+ +

+ This is the only time you'll see this API key. Make sure to copy it now. +

+
+ +
+
+ {newlyCreatedKey} +
+ +
+
+ ) : ( +
+ setNewKeyName(e.target.value)} + placeholder="Enter a name for your API key" + className="mb-2" + /> +
+ )} + + + {newlyCreatedKey ? ( + + ) : ( + <> + + + + )} + +
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx b/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx index 72858962..ddf37adb 100644 --- a/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx +++ b/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import Link from "next/link" import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" @@ -8,7 +9,7 @@ import { buttonVariants } from "@/components/ui/button" interface SidebarNavProps extends React.HTMLAttributes { items: { href: string - title: string + title: React.ReactNode }[] } diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index ce023831..fc131359 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -1,3 +1,4 @@ +import React from "react" import { Metadata } from "next" import { SidebarNav } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" @@ -5,6 +6,11 @@ import { Header } from "./components/header"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; +import { isServiceError } from "@/lib/utils"; +import { getMe, getOrgAccountRequests } from "@/actions"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { getOrgFromDomain } from "@/data/org"; +import { OrgRole } from "@prisma/client"; export const metadata: Metadata = { title: "Settings", @@ -22,6 +28,30 @@ export default async function SettingsLayout({ return redirect(`/${domain}`); } + const org = await getOrgFromDomain(domain); + if (!org) { + throw new Error("Organization not found"); + } + + const me = await getMe(); + if (isServiceError(me)) { + throw new ServiceErrorException(me); + } + + const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; + if (!userRoleInOrg) { + throw new Error("User role not found"); + } + + let numJoinRequests: number | undefined; + if (userRoleInOrg === OrgRole.OWNER) { + const requests = await getOrgAccountRequests(domain); + if (isServiceError(requests)) { + throw new ServiceErrorException(requests); + } + numJoinRequests = requests.length; + } + const sidebarNavItems = [ { title: "General", @@ -34,12 +64,29 @@ export default async function SettingsLayout({ } ] : []), { - title: "Members", + title: ( +
+ Members + {userRoleInOrg === OrgRole.OWNER && numJoinRequests !== undefined && numJoinRequests > 0 && ( + + {numJoinRequests} + + )} +
+ ), href: `/${domain}/settings/members`, }, { title: "Secrets", href: `/${domain}/settings/secrets`, + }, + { + title: "API Keys", + href: `/${domain}/settings/apiKeys`, + }, + { + title: "License", + href: `/${domain}/settings/license`, } ] diff --git a/packages/web/src/app/[domain]/settings/license/page.tsx b/packages/web/src/app/[domain]/settings/license/page.tsx new file mode 100644 index 00000000..74a27c97 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/license/page.tsx @@ -0,0 +1,121 @@ +import { getEntitlements, getLicenseKey, getPlan, SOURCEBOT_UNLIMITED_SEATS } from "@/features/entitlements/server"; +import { Button } from "@/components/ui/button"; +import { Info, Mail } from "lucide-react"; +import { getOrgMembers } from "@/actions"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; + +interface LicensePageProps { + params: { + domain: string; + } +} + +export default async function LicensePage({ params: { domain } }: LicensePageProps) { + const licenseKey = await getLicenseKey(); + const entitlements = await getEntitlements(); + const plan = await getPlan(); + + if (!licenseKey) { + return ( +
+
+

License

+

View your license details.

+
+ +
+ +

No License Found

+

+ Check out the docs for more information. +

+
+

+ Want to try out Sourcebot's enterprise features? Reach out to us and we'll get back to you within + a couple hours with a trial license. +

+
+ +
+
+ ) + } + + const members = await getOrgMembers(domain); + if (isServiceError(members)) { + throw new ServiceErrorException(members); + } + + const numMembers = members.length; + const expiryDate = new Date(licenseKey.expiryDate); + const isExpired = expiryDate < new Date(); + const seats = licenseKey.seats; + const isUnlimited = seats === SOURCEBOT_UNLIMITED_SEATS; + + return ( +
+
+
+

License

+

View your license details.

+
+ + +
+ +
+
+

License Details

+ +
+
+
License ID
+
{licenseKey.id}
+
+ +
+
Plan
+
{plan}
+
+ +
+
Entitlements
+
{entitlements?.join(", ") || "None"}
+
+ +
+
Seats
+
+ {isUnlimited ? 'Unlimited' : `${numMembers} / ${seats}`} +
+
+ +
+
Expiry Date
+
+ {expiryDate.toLocaleDateString("en-US", { + hour: "2-digit", + minute: "2-digit", + month: "long", + day: "numeric", + year: "numeric" + })} {isExpired && '(Expired)'} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx index 1c454df6..2e0aa15e 100644 --- a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx @@ -8,7 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useCallback, useState } from "react"; import { z } from "zod"; -import { PlusCircleIcon, Loader2 } from "lucide-react"; +import { PlusCircleIcon, Loader2, AlertCircle } from "lucide-react"; import { OrgRole } from "@prisma/client"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { createInvites } from "@/actions"; @@ -30,9 +30,10 @@ export const inviteMemberFormSchema = z.object({ interface InviteMemberCardProps { currentUserRole: OrgRole; isBillingEnabled: boolean; + seatsAvailable?: boolean; } -export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMemberCardProps) => { +export const InviteMemberCard = ({ currentUserRole, isBillingEnabled, seatsAvailable = true }: InviteMemberCardProps) => { const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const domain = useDomain(); @@ -81,13 +82,30 @@ export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMe }); }, [domain, form, toast, router, captureEvent]); + const isDisabled = !seatsAvailable || currentUserRole !== OrgRole.OWNER || isLoading; + return ( <> - + Invite Member Invite new members to your organization. + {!seatsAvailable && ( +
+
+ +
+

+ Maximum seats reached +

+

+ You've reached the maximum number of seats for your license. Upgrade your plan to invite additional members. +

+
+
+
+ )}
setIsInviteDialogOpen(true))}> @@ -104,6 +122,7 @@ export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMe {...field} className="max-w-md" placeholder="melissa@example.com" + disabled={isDisabled} /> @@ -119,6 +138,7 @@ export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMe variant="outline" size="sm" onClick={addEmailField} + disabled={isDisabled} > Add more @@ -128,7 +148,7 @@ export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMe + + + )} +
+
+ )) + )} +
+
+ + {/* Approve Request Dialog */} + + + + Approve Request + + Are you sure you want to approve the request from {requestToAction?.email}? They will be added as a member to your organization. + + + + + Back + + { + onApproveRequest(requestToAction?.id ?? ""); + }} + > + Approve + + + + + + {/* Reject Request Dialog */} + + + + Reject Request + + Are you sure you want to reject the request from {requestToAction?.email}? + + + + + Back + + { + onRejectRequest(requestToAction?.id ?? ""); + }} + > + Reject + + + + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index 7fb16123..edb96f35 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -6,9 +6,13 @@ import { InviteMemberCard } from "./components/inviteMemberCard"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { TabSwitcher } from "@/components/ui/tab-switcher"; import { InvitesList } from "./components/invitesList"; -import { getOrgInvites, getMe } from "@/actions"; +import { getOrgInvites, getMe, getOrgAccountRequests } from "@/actions"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { ServiceErrorException } from "@/lib/serviceError"; +import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@/features/entitlements/server"; +import { RequestsList } from "./components/requestsList"; +import { OrgRole } from "@prisma/client"; + interface MembersSettingsPageProps { params: { domain: string @@ -44,18 +48,40 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa throw new ServiceErrorException(invites); } + const requests = await getOrgAccountRequests(domain); + if (isServiceError(requests)) { + throw new ServiceErrorException(requests); + } + const currentTab = tab || "members"; + const seats = getSeats(); + const usedSeats = members.length + const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats; + return (
-
-

Members

-

Invite and manage members of your organization.

+
+
+

Members

+

Invite and manage members of your organization.

+
+ {seats && seats !== SOURCEBOT_UNLIMITED_SEATS && ( +
+
+ {usedSeats} + of + {seats} + seats used +
+
+ )}
@@ -64,26 +90,64 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa className="h-auto p-0 bg-transparent" tabs={[ { label: "Team Members", value: "members" }, - { label: "Pending Invites", value: "invites" }, + ...(userRoleInOrg === OrgRole.OWNER ? [ + { + label: ( +
+ Pending Requests + {requests.length > 0 && ( + + {requests.length} + + )} +
+ ), + value: "requests" + }, + { + label: ( +
+ Pending Invites + {invites.length > 0 && ( + + {invites.length} + + )} +
+ ), + value: "invites" + }, + ] : []), ]} currentTab={currentTab} />
- - - - - + + {userRoleInOrg === OrgRole.OWNER && ( + <> + + + + + + + + + )}
) diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index 20048ffa..6673f0eb 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -9,6 +9,7 @@ import { ErrorCode } from "@/lib/errorCodes"; export const GET = async (request: NextRequest) => { const domain = request.headers.get("X-Org-Domain"); + const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined; if (!domain) { return serviceErrorResponse({ statusCode: StatusCodes.BAD_REQUEST, @@ -17,7 +18,7 @@ export const GET = async (request: NextRequest) => { }); } - const response = await listRepositories(domain); + const response = await listRepositories(domain, apiKey); if (isServiceError(response)) { return serviceErrorResponse(response); } diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts index 2b80d3ec..145d3fa9 100644 --- a/packages/web/src/app/api/(server)/search/route.ts +++ b/packages/web/src/app/api/(server)/search/route.ts @@ -10,6 +10,7 @@ import { StatusCodes } from "http-status-codes"; export const POST = async (request: NextRequest) => { const domain = request.headers.get("X-Org-Domain"); + const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined; if (!domain) { return serviceErrorResponse({ statusCode: StatusCodes.BAD_REQUEST, @@ -26,7 +27,7 @@ export const POST = async (request: NextRequest) => { ); } - const response = await search(parsed.data, domain); + const response = await search(parsed.data, domain, apiKey); if (isServiceError(response)) { return serviceErrorResponse(response); } diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index c997ba72..a6364b36 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -10,6 +10,7 @@ import { StatusCodes } from "http-status-codes"; export const POST = async (request: NextRequest) => { const domain = request.headers.get("X-Org-Domain"); + const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined; if (!domain) { return serviceErrorResponse({ statusCode: StatusCodes.BAD_REQUEST, @@ -25,10 +26,8 @@ export const POST = async (request: NextRequest) => { schemaValidationError(parsed.error) ); } - - - const response = await getFileSource(parsed.data, domain); + const response = await getFileSource(parsed.data, domain, apiKey); if (isServiceError(response)) { return serviceErrorResponse(response); } diff --git a/packages/web/src/app/components/keyboardShortcutHint.tsx b/packages/web/src/app/components/keyboardShortcutHint.tsx index f93209f1..0bbff3c0 100644 --- a/packages/web/src/app/components/keyboardShortcutHint.tsx +++ b/packages/web/src/app/components/keyboardShortcutHint.tsx @@ -8,7 +8,13 @@ interface KeyboardShortcutHintProps { export function KeyboardShortcutHint({ shortcut, label }: KeyboardShortcutHintProps) { return (
- + {shortcut}
diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index feabe357..d43d68df 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -4,78 +4,163 @@ @layer base { :root { - --background: 0 0% 100%; - --background-secondary: 0, 0%, 98%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --background: hsl(0 0% 100%); + --background-secondary: hsl(0, 0%, 98%); + --foreground: hsl(37, 84%, 5%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(222.2 84% 4.9%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(222.2 84% 4.9%); + --primary: hsl(222.2 47.4% 11.2%); + --primary-foreground: hsl(210 40% 98%); + --secondary: hsl(210 40% 96.1%); + --secondary-foreground: hsl(222.2 47.4% 11.2%); + --muted: hsl(210 40% 96.1%); + --muted-foreground: hsl(215.4 16.3% 46.9%); + --accent: hsl(210 40% 96.1%); + --accent-foreground: hsl(222.2 47.4% 11.2%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(210 40% 98%); + --border: hsl(214.3 31.8% 91.4%); + --input: hsl(214.3 31.8% 91.4%); + --ring: hsl(222.2 84% 4.9%); --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --highlight: 224, 76%, 48%; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; + --chart-1: hsl(12 76% 61%); + --chart-2: hsl(173 58% 39%); + --chart-3: hsl(197 37% 24%); + --chart-4: hsl(43 74% 66%); + --chart-5: hsl(27 87% 67%); + --highlight: hsl(224, 76%, 48%); + --sidebar-background: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + + --editor-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --editor-font-size: 13px; + + --editor-background: var(--background); + --editor-foreground: var(--foreground); + --editor-caret: #3b4252; + --editor-selection: #eceff4; + --editor-selection-match: #e5e9f0; + --editor-gutter-background: var(--background); + --editor-gutter-foreground: #2e3440; + --editor-gutter-border: none; + --editor-gutter-active-foreground: #abb2bf; + --editor-line-highlight: #02255f11; + --editor-match-highlight: hsl(180, 70%, 40%); + + --editor-tag-keyword: #708; + --editor-tag-name: #256; + --editor-tag-function: #00f; + --editor-tag-label: #219; + --editor-tag-constant: #219; + --editor-tag-definition: #00c; + --editor-tag-brace: #219; + --editor-tag-type: #085; + --editor-tag-operator: #708; + --editor-tag-tag: #167; + --editor-tag-bracket-square: #219; + --editor-tag-bracket-angle: #219; + --editor-tag-attribute: #00c; + --editor-tag-string: #a11; + --editor-tag-link: inherit; + --editor-tag-meta: #404740; + --editor-tag-comment: #940; + --editor-tag-emphasis: inherit; + --editor-tag-heading: inherit; + --editor-tag-atom: #219; + --editor-tag-processing: #164; + --editor-tag-separator: #219; + --editor-tag-invalid: #f00; + --editor-tag-quote: #a11; + --editor-tag-annotation-special: #f00; + --editor-tag-number: #219; + --editor-tag-regexp: #e40; + --editor-tag-variable-local: #30a; } .dark { - --background: 222.2 84% 4.9%; - --background-secondary: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --highlight: 217, 91%, 60%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + --background: hsl(222.2 84% 4.9%); + --background-secondary: hsl(222.2 84% 4.9%); + --foreground: hsl(210 40% 98%); + --card: hsl(222.2 84% 4.9%); + --card-foreground: hsl(210 40% 98%); + --popover: hsl(222.2 84% 4.9%); + --popover-foreground: hsl(210 40% 98%); + --primary: hsl(210 40% 98%); + --primary-foreground: hsl(222.2 47.4% 11.2%); + --secondary: hsl(217.2 32.6% 17.5%); + --secondary-foreground: hsl(210 40% 98%); + --muted: hsl(217.2 32.6% 17.5%); + --muted-foreground: hsl(215 20.2% 65.1%); + --accent: hsl(217.2 32.6% 17.5%); + --accent-foreground: hsl(210 40% 98%); + --destructive: hsl(0 62.8% 30.6%); + --destructive-foreground: hsl(210 40% 98%); + --border: hsl(217.2 32.6% 17.5%); + --input: hsl(217.2 32.6% 17.5%); + --ring: hsl(212.7 26.8% 83.9%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --highlight: hsl(217 91% 60%); + --sidebar-background: hsl(240 5.9% 10%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + + --editor-background: var(--background); + --editor-foreground: #abb2bf; + --editor-caret: #528bff; + --editor-selection: #3E4451; + --editor-selection-match: #aafe661a; + --editor-gutter-background: var(--background); + --editor-gutter-foreground: #7d8799; + --editor-gutter-border: none; + --editor-gutter-active-foreground: #abb2bf; + --editor-line-highlight: hsl(219, 14%, 20%); + --editor-match-highlight: hsl(180, 70%, 30%); + + --editor-tag-keyword: #c678dd; + --editor-tag-name: #e06c75; + --editor-tag-function: #61afef; + --editor-tag-label: #61afef; + --editor-tag-constant: #d19a66; + --editor-tag-definition: #abb2bf; + --editor-tag-brace: #56b6c2; + --editor-tag-type: #e5c07b; + --editor-tag-operator: #56b6c2; + --editor-tag-tag: #e06c75; + --editor-tag-bracket-square: #56b6c2; + --editor-tag-bracket-angle: #56b6c2; + --editor-tag-attribute: #e5c07b; + --editor-tag-string: #98c379; + --editor-tag-link: #7d8799; + --editor-tag-meta: #7d8799; + --editor-tag-comment: #7d8799; + --editor-tag-emphasis: #e06c75; + --editor-tag-heading: #e06c75; + --editor-tag-atom: #d19a66; + --editor-tag-processing: #98c379; + --editor-tag-separator: #abb2bf; + --editor-tag-invalid: #ffffff; + --editor-tag-quote: #7d8799; + --editor-tag-annotation-special: #e5c07b; + --editor-tag-number: #e5c07b; + --editor-tag-regexp: #56b6c2; + --editor-tag-variable-local: #61afef; } } @@ -83,6 +168,7 @@ * { @apply border-border; } + body { @apply bg-background text-foreground; } @@ -98,13 +184,23 @@ text-align: left; } -.cm-editor .cm-searchMatch { - border: dotted; - background: transparent; +.searchMatch { + background: color-mix(in srgb, var(--editor-match-highlight) 25%, transparent); + border: 1px dashed var(--editor-match-highlight); + border-radius: 2px; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03); } -.cm-editor .cm-searchMatch-selected { - border: solid; +.searchMatch-selected { + background: color-mix(in srgb, var(--editor-match-highlight) 60%, transparent); + border: 1.5px solid var(--editor-match-highlight); + border-radius: 2px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.06); +} + +.lineHighlight { + background: var(--editor-line-highlight); + border-radius: 2px; } .cm-editor.cm-focused { @@ -123,8 +219,9 @@ @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; } + body { @apply bg-background text-foreground; } @@ -136,6 +233,22 @@ } .no-scrollbar { - -ms-overflow-style: none; /* IE dan Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; + /* IE dan Edge */ + scrollbar-width: none; + /* Firefox */ +} + +.cm-underline-hover { + text-decoration: none; + transition: text-decoration 0.1s; +} + +.cm-underline-hover:hover { + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + /* Optionally, customize color or thickness: */ + /* text-decoration-color: #0070f3; */ + /* text-decoration-thickness: 2px; */ } \ No newline at end of file diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index fa430930..55925c89 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -8,7 +8,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { SessionProvider } from "next-auth/react"; import { env } from "@/env.mjs"; import { PlanProvider } from "@/features/entitlements/planProvider"; -import { getPlan } from "@/features/entitlements/server"; +import { getEntitlements } from "@/features/entitlements/server"; export const metadata: Metadata = { title: "Sourcebot", @@ -29,7 +29,7 @@ export default function RootLayout({ - +

Sign in to your account

-
- -
+ {env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined && ( +
+ +
+ )} {error && (
diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx index 50fd3ef8..081c2bbb 100644 --- a/packages/web/src/app/login/page.tsx +++ b/packages/web/src/app/login/page.tsx @@ -12,8 +12,10 @@ interface LoginProps { } export default async function Login({ searchParams }: LoginProps) { + console.log("Login page loaded"); const session = await auth(); if (session) { + console.log("Session found in login page, redirecting to home"); return redirect("/"); } diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 4aa8c497..3aefd694 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -1,7 +1,5 @@ import 'next-auth/jwt'; import NextAuth, { DefaultSession, User as AuthJsUser } from "next-auth" -import GitHub from "next-auth/providers/github" -import Google from "next-auth/providers/google" import Credentials from "next-auth/providers/credentials" import EmailProvider from "next-auth/providers/nodemailer"; import { PrismaAdapter } from "@auth/prisma-adapter" @@ -14,8 +12,13 @@ import { verifyCredentialsRequestSchema } from './lib/schemas'; import { createTransport } from 'nodemailer'; import { render } from '@react-email/render'; import MagicLinkEmail from './emails/magicLinkEmail'; -import { SINGLE_TENANT_ORG_ID } from './lib/constants'; +import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID } from './lib/constants'; import bcrypt from 'bcryptjs'; +import { createAccountRequest } from './actions'; +import { getSSOProviders, handleJITProvisioning } from '@/ee/sso/sso'; +import { hasEntitlement } from '@/features/entitlements/server'; +import { isServiceError } from './lib/utils'; +import { ServiceErrorException } from './lib/serviceError'; export const runtime = 'nodejs'; @@ -36,21 +39,11 @@ declare module 'next-auth/jwt' { export const getProviders = () => { const providers: Provider[] = []; - if (env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET) { - providers.push(GitHub({ - clientId: env.AUTH_GITHUB_CLIENT_ID, - clientSecret: env.AUTH_GITHUB_CLIENT_SECRET, - })); - } - - if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) { - providers.push(Google({ - clientId: env.AUTH_GOOGLE_CLIENT_ID, - clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET, - })); + if (hasEntitlement("sso")) { + providers.push(...getSSOProviders()); } - if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) { + if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS && env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true') { providers.push(EmailProvider({ server: env.SMTP_CONNECTION_URL, from: env.EMAIL_FROM_ADDRESS, @@ -59,10 +52,9 @@ export const getProviders = () => { const token = String(Math.floor(100000 + Math.random() * 900000)); return token; }, - sendVerificationRequest: async ({ identifier, provider, token, request }) => { - const origin = request.headers.get('origin')!; + sendVerificationRequest: async ({ identifier, provider, token }) => { const transport = createTransport(provider.server); - const html = await render(MagicLinkEmail({ baseUrl: origin, token: token })); + const html = await render(MagicLinkEmail({ token: token })); const result = await transport.sendMail({ to: identifier, from: provider.from, @@ -140,24 +132,44 @@ export const getProviders = () => { } const onCreateUser = async ({ user }: { user: AuthJsUser }) => { - // In single-tenant mode w/ auth, we assign the first user to sign + // In single-tenant mode, we assign the first user to sign // up as the owner of the default org. if ( - env.SOURCEBOT_TENANCY_MODE === 'single' && - env.SOURCEBOT_AUTH_ENABLED === 'true' + env.SOURCEBOT_TENANCY_MODE === 'single' ) { - await prisma.$transaction(async (tx) => { - const defaultOrg = await tx.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, + const defaultOrg = await prisma.org.findUnique({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + include: { + members: { + where: { + role: { + not: OrgRole.GUEST, + } + } }, - include: { - members: true, + } + }); + + if (!defaultOrg) { + throw new Error("Default org not found on single tenant user creation"); + } + + // We can't use the getOrgMembers action here because we're not authed yet + const members = await prisma.userToOrg.findMany({ + where: { + orgId: SINGLE_TENANT_ORG_ID, + role: { + not: OrgRole.GUEST, } - }); + }, + }); - // Only the first user to sign up will be an owner of the default org. - if (defaultOrg?.members.length === 0) { + // Only the first user to sign up will be an owner of the default org. + const isFirstUser = members.length === 0; + if (isFirstUser) { + await prisma.$transaction(async (tx) => { await tx.org.update({ where: { id: SINGLE_TENANT_ORG_ID, @@ -175,8 +187,32 @@ const onCreateUser = async ({ user }: { user: AuthJsUser }) => { } } }); + + await tx.user.update({ + where: { + id: user.id, + }, + data: { + pendingApproval: false, + } + }); + }); + } else { + // TODO(auth): handle multi tenant case + if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) { + const res = await handleJITProvisioning(user.id!, SINGLE_TENANT_ORG_DOMAIN); + if (isServiceError(res)) { + console.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`); + throw new ServiceErrorException(res); + } + } else { + const res = await createAccountRequest(user.id!, SINGLE_TENANT_ORG_DOMAIN); + if (isServiceError(res)) { + console.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`); + throw new ServiceErrorException(res); + } } - }); + } } } diff --git a/packages/web/src/components/ui/animatedResizableHandle.tsx b/packages/web/src/components/ui/animatedResizableHandle.tsx new file mode 100644 index 00000000..c09635c4 --- /dev/null +++ b/packages/web/src/components/ui/animatedResizableHandle.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { ResizableHandle } from "./resizable"; + +export const AnimatedResizableHandle = () => { + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/components/ui/checkbox.tsx b/packages/web/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..df61a138 --- /dev/null +++ b/packages/web/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/packages/web/src/components/ui/data-table.tsx b/packages/web/src/components/ui/data-table.tsx index 27b373b8..94f83b56 100644 --- a/packages/web/src/components/ui/data-table.tsx +++ b/packages/web/src/components/ui/data-table.tsx @@ -71,6 +71,7 @@ export function DataTable({ className="max-w-sm" /> {/* + TODO(auth): Combine this logic with existing add repo button logic in AddRepoButton component Show a button on the demo site that allows users to add new repositories by updating the demo-site-config.json file and opening a PR. */} diff --git a/packages/web/src/components/ui/loading-button.tsx b/packages/web/src/components/ui/loading-button.tsx new file mode 100644 index 00000000..0639efd9 --- /dev/null +++ b/packages/web/src/components/ui/loading-button.tsx @@ -0,0 +1,30 @@ +'use client'; + +// @note: this is not a original Shadcn component. + +import { Button, ButtonProps } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import React from "react"; + +export interface LoadingButtonProps extends ButtonProps { + loading?: boolean; +} + +const LoadingButton = React.forwardRef(({ children, loading, ...props }, ref) => { + return ( + + ) +}); + +LoadingButton.displayName = "LoadingButton"; + +export { LoadingButton }; \ No newline at end of file diff --git a/packages/web/src/components/ui/switch.tsx b/packages/web/src/components/ui/switch.tsx new file mode 100644 index 00000000..b4ce08a1 --- /dev/null +++ b/packages/web/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/packages/web/src/components/ui/tab-switcher.tsx b/packages/web/src/components/ui/tab-switcher.tsx index 0749e77f..a1797ae9 100644 --- a/packages/web/src/components/ui/tab-switcher.tsx +++ b/packages/web/src/components/ui/tab-switcher.tsx @@ -2,9 +2,10 @@ import { useRouter } from "next/navigation" import { TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ReactNode } from "react" interface TabSwitcherProps { - tabs: { value: string; label: string }[] + tabs: { value: string; label: ReactNode }[] currentTab: string className?: string } diff --git a/packages/web/src/ee/features/billing/actions.ts b/packages/web/src/ee/features/billing/actions.ts index b1068944..a5e8f186 100644 --- a/packages/web/src/ee/features/billing/actions.ts +++ b/packages/web/src/ee/features/billing/actions.ts @@ -14,18 +14,8 @@ import { headers } from "next/headers"; import { getSubscriptionForOrg } from "./serverUtils"; export const createOnboardingSubscription = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - return notFound(); - } - + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { const user = await getMe(); if (isServiceError(user)) { return user; @@ -64,7 +54,7 @@ export const createOnboardingSubscription = async (domain: string) => sew(() => return customer.id; })(); - const existingSubscription = await getSubscriptionForOrg(orgId, prisma); + const existingSubscription = await getSubscriptionForOrg(org.id, prisma); if (!isServiceError(existingSubscription)) { return { statusCode: StatusCodes.BAD_REQUEST, @@ -119,15 +109,9 @@ export const createOnboardingSubscription = async (domain: string) => sew(() => )); export const createStripeCheckoutSession = async (domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org || !org.stripeCustomerId) { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + if (!org.stripeCustomerId) { return notFound(); } @@ -137,7 +121,7 @@ export const createStripeCheckoutSession = async (domain: string) => sew(() => const orgMembers = await prisma.userToOrg.findMany({ where: { - orgId, + orgId: org.id, }, select: { userId: true, @@ -181,15 +165,9 @@ export const createStripeCheckoutSession = async (domain: string) => sew(() => )); export const getCustomerPortalSessionLink = async (domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org || !org.stripeCustomerId) { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + if (!org.stripeCustomerId) { return notFound(); } @@ -208,15 +186,9 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org || !org.stripeCustomerId) { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + if (!org.stripeCustomerId) { return notFound(); } @@ -233,15 +205,9 @@ export const getSubscriptionBillingEmail = async (domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org || !org.stripeCustomerId) { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + if (!org.stripeCustomerId) { return notFound(); } @@ -260,9 +226,9 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s )); export const getSubscriptionInfo = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const subscription = await getSubscriptionForOrg(orgId, prisma); + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const subscription = await getSubscriptionForOrg(org.id, prisma); if (isServiceError(subscription)) { return subscription; diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/index.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/index.tsx new file mode 100644 index 00000000..70b825c4 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/index.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { Badge } from "@/components/ui/badge"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { findSearchBasedSymbolDefinitions, findSearchBasedSymbolReferences } from "@/features/codeNav/actions"; +import { useDomain } from "@/hooks/useDomain"; +import { unwrapServiceError } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import clsx from "clsx"; +import { Loader2 } from "lucide-react"; +import { useMemo } from "react"; +import { VscSymbolMisc } from "react-icons/vsc"; +import { ReferenceList } from "./referenceList"; + +interface ExploreMenuProps { + selectedSymbolInfo: { + symbolName: string; + repoName: string; + revisionName: string; + language: string; + } +} + +export const ExploreMenu = ({ + selectedSymbolInfo, +}: ExploreMenuProps) => { + + const domain = useDomain(); + const { + state: { activeExploreMenuTab }, + updateBrowseState, + } = useBrowseState(); + + const { + data: referencesResponse, + isError: isReferencesResponseError, + isPending: isReferencesResponsePending, + isLoading: isReferencesResponseLoading, + } = useQuery({ + queryKey: ["references", selectedSymbolInfo.symbolName, selectedSymbolInfo.repoName, selectedSymbolInfo.revisionName, selectedSymbolInfo.language, domain], + queryFn: () => unwrapServiceError( + findSearchBasedSymbolReferences({ + symbolName: selectedSymbolInfo.symbolName, + language: selectedSymbolInfo.language, + revisionName: selectedSymbolInfo.revisionName, + }, domain) + ), + }); + + const { + data: definitionsResponse, + isError: isDefinitionsResponseError, + isPending: isDefinitionsResponsePending, + isLoading: isDefinitionsResponseLoading, + } = useQuery({ + queryKey: ["definitions", selectedSymbolInfo.symbolName, selectedSymbolInfo.repoName, selectedSymbolInfo.revisionName, selectedSymbolInfo.language, domain], + queryFn: () => unwrapServiceError( + findSearchBasedSymbolDefinitions({ + symbolName: selectedSymbolInfo.symbolName, + language: selectedSymbolInfo.language, + revisionName: selectedSymbolInfo.revisionName, + }, domain) + ), + }); + + const isPending = isReferencesResponsePending || isDefinitionsResponsePending; + const isLoading = isReferencesResponseLoading || isDefinitionsResponseLoading; + const isError = isDefinitionsResponseError || isReferencesResponseError; + + if (isPending || isLoading) { + return ( +
+ + Loading... +
+ ) + } + + if (isError) { + return ( +
+

Error loading {activeExploreMenuTab}

+
+ ) + } + + const data = activeExploreMenuTab === "references" ? + referencesResponse : + definitionsResponse; + + return ( + + +
+ + + + Search Based + + + + Symbol references and definitions found using a best-guess search heuristic. + + +
+ { + updateBrowseState({ activeExploreMenuTab: "references" }); + }} + /> + { + updateBrowseState({ activeExploreMenuTab: "definitions" }); + }} + /> +
+
+
+ + + {data.files.length > 0 ? ( + + ) : ( +
+ +

No {activeExploreMenuTab} found

+
+ )} +
+
+ + ) +} + +interface EntryProps { + name: string; + isSelected: boolean; + count?: number; + onClicked: () => void; +} + +const Entry = ({ + name, + isSelected, + count, + onClicked, +}: EntryProps) => { + const countText = useMemo(() => { + if (count === undefined) { + return "?"; + } + + if (count > 999) { + return "999+"; + } + return count.toString(); + }, [count]); + + return ( +
onClicked()} + > +

{name}

+
+ {countText} +
+
+ ); +} diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx new file mode 100644 index 00000000..8b3acd80 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; +import { FileHeader } from "@/app/[domain]/components/fileHeader"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { FindRelatedSymbolsResponse } from "@/features/codeNav/types"; +import { RepositoryInfo, SourceRange } from "@/features/search/types"; +import { base64Decode } from "@/lib/utils"; +import { useMemo } from "react"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface ReferenceListProps { + data: FindRelatedSymbolsResponse; + revisionName: string; +} + +export const ReferenceList = ({ + data, + revisionName, +}: ReferenceListProps) => { + const repoInfoMap = useMemo(() => { + return data.repositoryInfo.reduce((acc, repo) => { + acc[repo.id] = repo; + return acc; + }, {} as Record); + }, [data.repositoryInfo]); + + const { navigateToPath } = useBrowseNavigation(); + const captureEvent = useCaptureEvent(); + + return ( + + {data.files.map((file, index) => { + const repoInfo = repoInfoMap[file.repositoryId]; + + return ( +
+
+ +
+
+ {file.matches + .sort((a, b) => a.range.start.lineNumber - b.range.start.lineNumber) + .map((match, index) => ( + { + captureEvent('wa_explore_menu_reference_clicked', {}); + navigateToPath({ + repoName: file.repository, + revisionName, + path: file.fileName, + pathType: 'blob', + highlightRange: match.range, + }) + }} + /> + ))} +
+
+ ) + })} +
+ ) +} + + +interface ReferenceListItemProps { + lineContent: string; + range: SourceRange; + language: string; + onClick: () => void; +} + +const ReferenceListItem = ({ + lineContent, + range, + language, + onClick, +}: ReferenceListItemProps) => { + const decodedLineContent = useMemo(() => { + return base64Decode(lineContent); + }, [lineContent]); + + const highlightRanges = useMemo(() => [range], [range]); + + return ( +
+ + {decodedLineContent} + +
+ ) +} diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx new file mode 100644 index 00000000..86c0d0f5 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx @@ -0,0 +1,138 @@ +import { Button } from "@/components/ui/button"; +import { LoadingButton } from "@/components/ui/loading-button"; +import { Separator } from "@/components/ui/separator"; +import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react"; +import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { Loader2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { SymbolDefinition, useHoveredOverSymbolInfo } from "./useHoveredOverSymbolInfo"; +import { SymbolDefinitionPreview } from "./symbolDefinitionPreview"; + +interface SymbolHoverPopupProps { + editorRef: ReactCodeMirrorRef; + language: string; + revisionName: string; + onFindReferences: (symbolName: string) => void; + onGotoDefinition: (symbolName: string, symbolDefinitions: SymbolDefinition[]) => void; +} + +export const SymbolHoverPopup: React.FC = ({ + editorRef, + revisionName, + language, + onFindReferences, + onGotoDefinition: _onGotoDefinition, +}) => { + const ref = useRef(null); + const [isSticky, setIsSticky] = useState(false); + + const symbolInfo = useHoveredOverSymbolInfo({ + editorRef, + isSticky, + revisionName, + language, + }); + + // Positions the popup relative to the symbol + useEffect(() => { + if (!symbolInfo) { + return; + } + + const virtualElement: VirtualElement = { + getBoundingClientRect: () => { + return symbolInfo.element.getBoundingClientRect(); + } + } + + if (ref.current) { + computePosition(virtualElement, ref.current, { + placement: 'top', + middleware: [ + offset(2), + flip({ + mainAxis: true, + crossAxis: false, + fallbackPlacements: ['bottom'], + boundary: editorRef.view?.dom, + }), + shift({ + padding: 5, + }) + ] + }).then(({ x, y }) => { + if (ref.current) { + ref.current.style.left = `${x}px`; + ref.current.style.top = `${y}px`; + } + }) + } + }, [symbolInfo, editorRef]); + + const onGotoDefinition = useCallback(() => { + if (!symbolInfo || !symbolInfo.symbolDefinitions) { + return; + } + + _onGotoDefinition(symbolInfo.symbolName, symbolInfo.symbolDefinitions); + }, [symbolInfo, _onGotoDefinition]); + + // @todo: We should probably make the behaviour s.t., the ctrl / cmd key needs to be held + // down to navigate to the definition. We should also only show the underline when the key + // is held, hover is active, and we have found the symbol definition. + useEffect(() => { + if (!symbolInfo || !symbolInfo.symbolDefinitions) { + return; + } + + symbolInfo.element.addEventListener("click", onGotoDefinition); + return () => { + symbolInfo.element.removeEventListener("click", onGotoDefinition); + } + }, [symbolInfo, onGotoDefinition]); + + return symbolInfo ? ( +
setIsSticky(true)} + onMouseOut={() => setIsSticky(false)} + > + {symbolInfo.isSymbolDefinitionsLoading ? ( +
+ + Loading... +
+ ) : symbolInfo.symbolDefinitions && symbolInfo.symbolDefinitions.length > 0 ? ( + + ) : ( +

No hover info found

+ )} + +
+ + { + !symbolInfo.isSymbolDefinitionsLoading && (!symbolInfo.symbolDefinitions || symbolInfo.symbolDefinitions.length === 0) ? + "No definition found" : + `Go to ${symbolInfo.symbolDefinitions && symbolInfo.symbolDefinitions.length > 1 ? "definitions" : "definition"}` + } + + +
+
+ ) : null; +}; diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolDefinitionPreview.tsx b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolDefinitionPreview.tsx new file mode 100644 index 00000000..92cb69b4 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolDefinitionPreview.tsx @@ -0,0 +1,62 @@ +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; +import { useMemo } from "react"; +import { SourceRange } from "@/features/search/types"; +import { base64Decode } from "@/lib/utils"; + +interface SymbolDefinitionPreviewProps { + symbolDefinition: { + lineContent: string; + language: string; + fileName: string; + repoName: string; + range: SourceRange; + }; +} + +export const SymbolDefinitionPreview = ({ + symbolDefinition, +}: SymbolDefinitionPreviewProps) => { + const { lineContent, language, range } = symbolDefinition; + const highlightRanges = useMemo(() => [range], [range]); + + const decodedLineContent = useMemo(() => { + return base64Decode(lineContent); + }, [lineContent]); + + return ( +
+ + + + Search Based + + + + Symbol definition found using a best-guess search heuristic. + + + + {decodedLineContent} + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts new file mode 100644 index 00000000..179a0a54 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts @@ -0,0 +1,78 @@ +import { StateField, Range } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { measureSync } from "@/lib/utils"; + +export const SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE = "data-symbol-hover-target"; + +const decoration = Decoration.mark({ + class: "cm-underline-hover", + attributes: { [SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE]: "true" } +}); + +const NODE_TYPES = [ + // Typescript + Python + "VariableName", + "VariableDefinition", + "TypeDefinition", + "TypeName", + "PropertyName", + "PropertyDefinition", + "JSXIdentifier", + "Identifier", + // C# + "VarName", + "TypeIdentifier", + "PropertyName", + "MethodName", + "Ident", + "ParamName", + "AttrsNamedArg", + // C/C++ + "Identifier", + "NamespaceIdentifier", + "FieldIdentifier", + // Objective-C + "variableName", + "variableName.definition", + // Java + "Definition", + // Rust + "BoundIdentifier", + // Go + "DefName", + "FieldName", + // PHP + "ClassMemberName", + "Name" +] + +export const symbolHoverTargetsExtension = StateField.define({ + create(state) { + // @note: we need to use `ensureSyntaxTree` here (as opposed to `syntaxTree`) + // because we want to parse the entire document, not just the text visible in + // the current viewport. + const { data: tree } = measureSync(() => ensureSyntaxTree(state, state.doc.length), "ensureSyntaxTree"); + const decorations: Range[] = []; + + // @note: useful for debugging + // const getTextAt = (from: number, to: number) => { + // const doc = state.doc; + // return doc.sliceString(from, to); + // } + + tree?.iterate({ + enter: (node) => { + // console.log(node.type.name, getTextAt(node.from, node.to)); + if (NODE_TYPES.includes(node.type.name)) { + decorations.push(decoration.range(node.from, node.to)); + } + }, + }); + return Decoration.set(decorations); + }, + update(deco, tr) { + return deco.map(tr.changes); + }, + provide: field => EditorView.decorations.from(field), +}); \ No newline at end of file diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo.ts b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo.ts new file mode 100644 index 00000000..f21462b1 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo.ts @@ -0,0 +1,139 @@ +import { findSearchBasedSymbolDefinitions } from "@/features/codeNav/actions"; +import { SourceRange } from "@/features/search/types"; +import { useDomain } from "@/hooks/useDomain"; +import { unwrapServiceError } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE } from "./symbolHoverTargetsExtension"; + +interface UseHoveredOverSymbolInfoProps { + editorRef: ReactCodeMirrorRef; + isSticky: boolean; + revisionName: string; + language: string; +} + +export type SymbolDefinition = { + lineContent: string; + language: string; + fileName: string; + repoName: string; + range: SourceRange; +} + +interface HoveredOverSymbolInfo { + element: HTMLElement; + symbolName: string; + isSymbolDefinitionsLoading: boolean; + symbolDefinitions?: SymbolDefinition[]; +} + +const SYMBOL_HOVER_POPUP_MOUSE_OVER_TIMEOUT_MS = 500; +const SYMBOL_HOVER_POPUP_MOUSE_OUT_TIMEOUT_MS = 100; + +export const useHoveredOverSymbolInfo = ({ + editorRef, + isSticky, + revisionName, + language, +}: UseHoveredOverSymbolInfoProps): HoveredOverSymbolInfo | undefined => { + const mouseOverTimerRef = useRef(null); + const mouseOutTimerRef = useRef(null); + + const domain = useDomain(); + const [isVisible, setIsVisible] = useState(false); + + const [symbolElement, setSymbolElement] = useState(null); + const symbolName = useMemo(() => { + return (symbolElement && symbolElement.textContent) ?? undefined; + }, [symbolElement]); + + const { data: symbolDefinitions, isLoading: isSymbolDefinitionsLoading } = useQuery({ + queryKey: ["definitions", symbolName, revisionName, language, domain], + queryFn: () => unwrapServiceError( + findSearchBasedSymbolDefinitions({ + symbolName: symbolName!, + language, + revisionName, + }, domain) + ), + select: ((data) => { + return data.files.flatMap((file) => { + return file.matches.map((match) => { + return { + lineContent: match.lineContent, + language: file.language, + fileName: file.fileName, + repoName: file.repository, + range: match.range, + } + }) + }) + + }), + enabled: !!symbolName, + staleTime: Infinity, + }) + + const clearTimers = useCallback(() => { + if (mouseOverTimerRef.current) { + clearTimeout(mouseOverTimerRef.current); + } + + if (mouseOutTimerRef.current) { + clearTimeout(mouseOutTimerRef.current); + } + }, []); + + useEffect(() => { + const view = editorRef.view; + if (!view) { + return; + } + + const handleMouseOver = (event: MouseEvent) => { + const target = (event.target as HTMLElement).closest(`[${SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE}="true"]`) as HTMLElement; + if (!target) { + return; + } + clearTimers(); + setSymbolElement(target); + + mouseOverTimerRef.current = setTimeout(() => { + setIsVisible(true); + }, SYMBOL_HOVER_POPUP_MOUSE_OVER_TIMEOUT_MS); + }; + + const handleMouseOut = () => { + clearTimers(); + + mouseOutTimerRef.current = setTimeout(() => { + setIsVisible(false); + }, SYMBOL_HOVER_POPUP_MOUSE_OUT_TIMEOUT_MS); + }; + + view.dom.addEventListener("mouseover", handleMouseOver); + view.dom.addEventListener("mouseout", handleMouseOut); + + return () => { + view.dom.removeEventListener("mouseover", handleMouseOver); + view.dom.removeEventListener("mouseout", handleMouseOut); + }; + }, [editorRef, domain, clearTimers]); + + if (!isVisible && !isSticky) { + return undefined; + } + + if (!symbolElement || !symbolName) { + return undefined; + } + + return { + element: symbolElement, + symbolName, + isSymbolDefinitionsLoading: isSymbolDefinitionsLoading, + symbolDefinitions, + }; +} diff --git a/packages/web/src/ee/features/publicAccess/publicAccess.tsx b/packages/web/src/ee/features/publicAccess/publicAccess.tsx new file mode 100644 index 00000000..36ca4bba --- /dev/null +++ b/packages/web/src/ee/features/publicAccess/publicAccess.tsx @@ -0,0 +1,139 @@ +"use server"; + +import { ServiceError } from "@/lib/serviceError"; +import { getOrgFromDomain } from "@/data/org"; +import { orgMetadataSchema } from "@/types"; +import { ErrorCode } from "@/lib/errorCodes"; +import { StatusCodes } from "http-status-codes"; +import { prisma } from "@/prisma"; +import { sew } from "@/actions"; +import { getPlan, hasEntitlement } from "@/features/entitlements/server"; +import { SOURCEBOT_GUEST_USER_EMAIL, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; +import { OrgRole } from "@sourcebot/db"; + +export const getPublicAccessStatus = async (domain: string): Promise => sew(async () => { + const org = await getOrgFromDomain(domain); + 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 = orgMetadataSchema.safeParse(org.metadata); + if (!orgMetadata.success) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.INVALID_ORG_METADATA, + message: "Invalid organization metadata", + } satisfies ServiceError; + } + + return !!orgMetadata.data.publicAccessEnabled; +}); + +export const setPublicAccessStatus = async (domain: string, enabled: boolean): Promise => sew(async () => { + const hasPublicAccessEntitlement = hasEntitlement("public-access"); + if (!hasPublicAccessEntitlement) { + const plan = getPlan(); + console.error(`Public access isn't 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}.`); + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: "Public access is not supported in your current plan", + } satisfies ServiceError; + } + + const org = await getOrgFromDomain(domain); + if (!org) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.NOT_FOUND, + message: "Organization not found", + } satisfies ServiceError; + } + + const currentMetadata = orgMetadataSchema.safeParse(org.metadata); + const mergedMetadata = { + ...(currentMetadata.success ? currentMetadata.data : {}), + publicAccessEnabled: enabled, + }; + + await prisma.org.update({ + where: { + id: org.id, + }, + data: { + metadata: mergedMetadata, + }, + }); + + return true; +}); + +export const createGuestUser = async (domain: string): Promise => sew(async () => { + const hasPublicAccessEntitlement = hasEntitlement("public-access"); + if (!hasPublicAccessEntitlement) { + console.error(`Public access isn't supported in your current plan: ${getPlan()}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: "Public access is not supported in your current plan", + } satisfies ServiceError; + } + + const org = await getOrgFromDomain(domain); + if (!org) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.NOT_FOUND, + message: "Organization not found", + } satisfies ServiceError; + } + + const user = await prisma.user.upsert({ + where: { + id: SOURCEBOT_GUEST_USER_ID, + }, + update: {}, + create: { + id: SOURCEBOT_GUEST_USER_ID, + name: "Guest", + email: SOURCEBOT_GUEST_USER_EMAIL, + pendingApproval: false, + }, + }); + + await prisma.org.update({ + where: { + id: org.id, + }, + data: { + members: { + upsert: { + where: { + orgId_userId: { + orgId: org.id, + userId: user.id, + }, + }, + update: {}, + create: { + role: OrgRole.GUEST, + user: { + connect: { id: user.id }, + }, + }, + }, + }, + }, + }); + + return true; +}); diff --git a/packages/web/src/ee/sso/sso.tsx b/packages/web/src/ee/sso/sso.tsx new file mode 100644 index 00000000..c3d80333 --- /dev/null +++ b/packages/web/src/ee/sso/sso.tsx @@ -0,0 +1,168 @@ +import type { Provider } from "next-auth/providers"; +import { env } from "@/env.mjs"; +import GitHub from "next-auth/providers/github"; +import Google from "next-auth/providers/google"; +import Okta from "next-auth/providers/okta"; +import Keycloak from "next-auth/providers/keycloak"; +import Gitlab from "next-auth/providers/gitlab"; +import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; +import { prisma } from "@/prisma"; +import { notFound, ServiceError } from "@/lib/serviceError"; +import { OrgRole } from "@sourcebot/db"; +import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@/features/entitlements/server"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "@/lib/errorCodes"; +import { sew } from "@/actions"; + +export const getSSOProviders = (): Provider[] => { + const providers: Provider[] = []; + + if (env.AUTH_EE_GITHUB_CLIENT_ID && env.AUTH_EE_GITHUB_CLIENT_SECRET) { + const baseUrl = env.AUTH_EE_GITHUB_BASE_URL ?? "https://github.com"; + const apiUrl = env.AUTH_EE_GITHUB_BASE_URL ? `${env.AUTH_EE_GITHUB_BASE_URL}/api/v3` : "https://api.github.com"; + providers.push(GitHub({ + clientId: env.AUTH_EE_GITHUB_CLIENT_ID, + clientSecret: env.AUTH_EE_GITHUB_CLIENT_SECRET, + authorization: { + url: `${baseUrl}/login/oauth/authorize`, + params: { + scope: "read:user user:email", + }, + }, + token: { + url: `${baseUrl}/login/oauth/access_token`, + }, + userinfo: { + url: `${apiUrl}/user`, + }, + })); + } + + if (env.AUTH_EE_GITLAB_CLIENT_ID && env.AUTH_EE_GITLAB_CLIENT_SECRET) { + providers.push(Gitlab({ + clientId: env.AUTH_EE_GITLAB_CLIENT_ID, + clientSecret: env.AUTH_EE_GITLAB_CLIENT_SECRET, + authorization: { + url: `${env.AUTH_EE_GITLAB_BASE_URL}/oauth/authorize`, + params: { + scope: "read_user", + }, + }, + token: { + url: `${env.AUTH_EE_GITLAB_BASE_URL}/oauth/token`, + }, + userinfo: { + url: `${env.AUTH_EE_GITLAB_BASE_URL}/api/v4/user`, + }, + })); + } + + if (env.AUTH_EE_GOOGLE_CLIENT_ID && env.AUTH_EE_GOOGLE_CLIENT_SECRET) { + providers.push(Google({ + clientId: env.AUTH_EE_GOOGLE_CLIENT_ID, + clientSecret: env.AUTH_EE_GOOGLE_CLIENT_SECRET, + })); + } + + if (env.AUTH_EE_OKTA_CLIENT_ID && env.AUTH_EE_OKTA_CLIENT_SECRET && env.AUTH_EE_OKTA_ISSUER) { + providers.push(Okta({ + clientId: env.AUTH_EE_OKTA_CLIENT_ID, + clientSecret: env.AUTH_EE_OKTA_CLIENT_SECRET, + issuer: env.AUTH_EE_OKTA_ISSUER, + })); + } + + if (env.AUTH_EE_KEYCLOAK_CLIENT_ID && env.AUTH_EE_KEYCLOAK_CLIENT_SECRET && env.AUTH_EE_KEYCLOAK_ISSUER) { + providers.push(Keycloak({ + clientId: env.AUTH_EE_KEYCLOAK_CLIENT_ID, + clientSecret: env.AUTH_EE_KEYCLOAK_CLIENT_SECRET, + issuer: env.AUTH_EE_KEYCLOAK_ISSUER, + })); + } + + if (env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID && env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET && env.AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER) { + providers.push(MicrosoftEntraID({ + clientId: env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID, + clientSecret: env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET, + issuer: env.AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER, + })); + } + + return providers; +} + +export const handleJITProvisioning = async (userId: string, domain: string): Promise => sew(async () => { + const org = await prisma.org.findUnique({ + where: { + domain, + }, + include: { + members: { + where: { + role: { + not: OrgRole.GUEST, + } + } + } + } + }); + + if (!org) { + return notFound(`Org ${domain} not found`); + } + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) { + return notFound(`User ${userId} not found`); + } + + const userToOrg = await prisma.userToOrg.findFirst({ + where: { + userId, + orgId: org.id, + } + }); + + if (userToOrg) { + console.warn(`JIT provisioning skipped for user ${userId} since they're already a member of org ${domain}`); + return true; + } + + const seats = await getSeats(); + const memberCount = org.members.length; + + if (seats != SOURCEBOT_UNLIMITED_SEATS && memberCount >= seats) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Failed to provision user since the organization is at max capacity", + } satisfies ServiceError; + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { + id: userId, + }, + data: { + pendingApproval: false, + }, + }); + + await tx.userToOrg.create({ + data: { + userId, + orgId: org.id, + role: OrgRole.MEMBER, + }, + }); + }); + + return true; +}); + diff --git a/packages/web/src/emails/constants.ts b/packages/web/src/emails/constants.ts new file mode 100644 index 00000000..1dcbb3ef --- /dev/null +++ b/packages/web/src/emails/constants.ts @@ -0,0 +1,3 @@ +export const SOURCEBOT_LOGO_LIGHT_LARGE_URL = "https://framerusercontent.com/images/hFtwmNtuQSpgYuypFCICKr384.png"; +export const SOURCEBOT_ARROW_IMAGE_URL = "https://framerusercontent.com/images/hEk6w8i85onEQ3mrn1jhHzq8c.png"; +export const SOURCEBOT_PLACEHOLDER_AVATAR_URL = "https://framerusercontent.com/images/YW1HBfmcq7oLix1MFvLJObMv4Q.png"; diff --git a/packages/web/src/emails/inviteUserEmail.tsx b/packages/web/src/emails/inviteUserEmail.tsx index 02aa85cf..23400ca5 100644 --- a/packages/web/src/emails/inviteUserEmail.tsx +++ b/packages/web/src/emails/inviteUserEmail.tsx @@ -15,9 +15,10 @@ import { Text, } from '@react-email/components'; import { EmailFooter } from './emailFooter'; +import { SOURCEBOT_LOGO_LIGHT_LARGE_URL, SOURCEBOT_ARROW_IMAGE_URL, SOURCEBOT_PLACEHOLDER_AVATAR_URL } from './constants'; + interface InviteUserEmailProps { inviteLink: string; - baseUrl: string; host: { email: string; name?: string; @@ -31,7 +32,6 @@ interface InviteUserEmailProps { } export const InviteUserEmail = ({ - baseUrl, host, recipient, orgName, @@ -49,7 +49,7 @@ export const InviteUserEmail = ({
Sourcebot Logo invited you to @@ -128,17 +128,16 @@ const InvitedByText = ({ email, name }: { email: string, name?: string }) => { } InviteUserEmail.PreviewProps = { - baseUrl: 'http://localhost:3000', host: { name: 'Alan Turing', email: 'alan.turing@example.com', - // avatarUrl: `http://localhost:3000/arrow.png`, + avatarUrl: SOURCEBOT_PLACEHOLDER_AVATAR_URL, }, recipient: { // name: 'alanturing', }, orgName: 'Enigma', - orgImageUrl: `http://localhost:3000/arrow.png`, + orgImageUrl: SOURCEBOT_PLACEHOLDER_AVATAR_URL, inviteLink: 'https://localhost:3000/redeem?invite_id=1234', } satisfies InviteUserEmailProps; diff --git a/packages/web/src/emails/joinRequestApprovedEmail.tsx b/packages/web/src/emails/joinRequestApprovedEmail.tsx new file mode 100644 index 00000000..d9a5a837 --- /dev/null +++ b/packages/web/src/emails/joinRequestApprovedEmail.tsx @@ -0,0 +1,96 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { EmailFooter } from './emailFooter'; +import { SOURCEBOT_LOGO_LIGHT_LARGE_URL } from './constants'; + +interface JoinRequestApprovedEmailProps { + baseUrl: string; + user: { + email: string; + name?: string; + avatarUrl?: string; + }, + orgName: string; + orgDomain: string; +} + +export const JoinRequestApprovedEmail = ({ + baseUrl, + user, + orgName, + orgDomain, +}: JoinRequestApprovedEmailProps) => { + const previewText = `Your request to join ${orgName} on Sourcebot has been approved`; + const orgLink = `${baseUrl}/${orgDomain}`; + + return ( + + + + + {previewText} + +
+ Sourcebot Logo +
+ + Welcome to {orgName} + + + Hello{user.name ? ` ${user.name}` : ''}, + + + Your request to join {orgName} on Sourcebot has been approved. You now have access to the organization. + +
+ +
+ + or copy and paste this URL into your browser:{' '} + + {orgLink} + + + +
+ +
+ + ); +}; + +JoinRequestApprovedEmail.PreviewProps = { + baseUrl: 'http://localhost:3000', + user: { + name: 'Alan Turing', + email: 'alan.turing@example.com', + avatarUrl: `http://localhost:3000/placeholder_avatar.png`, + }, + orgName: 'Enigma', + orgDomain: '~', +} satisfies JoinRequestApprovedEmailProps; + +export default JoinRequestApprovedEmail; \ No newline at end of file diff --git a/packages/web/src/emails/joinRequestSubmittedEmail.tsx b/packages/web/src/emails/joinRequestSubmittedEmail.tsx new file mode 100644 index 00000000..feb20dcc --- /dev/null +++ b/packages/web/src/emails/joinRequestSubmittedEmail.tsx @@ -0,0 +1,141 @@ +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { EmailFooter } from './emailFooter'; +import { SOURCEBOT_LOGO_LIGHT_LARGE_URL, SOURCEBOT_ARROW_IMAGE_URL, SOURCEBOT_PLACEHOLDER_AVATAR_URL } from './constants'; + +interface JoinRequestSubmittedEmailProps { + baseUrl: string; + requestor: { + email: string; + name?: string; + avatarUrl?: string; + }, + orgName: string; + orgDomain: string; + orgImageUrl?: string; +} + +export const JoinRequestSubmittedEmail = ({ + baseUrl, + requestor, + orgName, + orgDomain, + orgImageUrl, +}: JoinRequestSubmittedEmailProps) => { + const previewText = `${requestor.name ?? requestor.email} has requested to join ${orgName} on Sourcebot`; + const reviewLink = `${baseUrl}/${encodeURIComponent(orgDomain)}/settings/members`; + + return ( + + + + + {previewText} + +
+ Sourcebot Logo +
+ + New Join Request for {orgName} + + + Hello, + + + has requested to join your organization {orgName} on Sourcebot. + +
+ + + Requestor avatar + + + requesting to join + + + Organization avatar + + +
+
+ +
+ + or copy and paste this URL into your browser:{' '} + + {reviewLink} + + + +
+ +
+ + ); +}; + +const RequestorInfo = ({ email, name }: { email: string, name?: string }) => { + const emailElement = {email}; + + if (name) { + return {name} ({emailElement}); + } + + return emailElement; +} + +JoinRequestSubmittedEmail.PreviewProps = { + baseUrl: 'http://localhost:3000', + requestor: { + name: 'Alan Turing', + email: 'alan.turing@example.com', + avatarUrl: `http://localhost:3000/placeholder_avatar.png`, + }, + orgName: 'Enigma', + orgDomain: '~', + orgImageUrl: `http://localhost:3000/placeholder_avatar.png`, +} satisfies JoinRequestSubmittedEmailProps; + +export default JoinRequestSubmittedEmail; diff --git a/packages/web/src/emails/magicLinkEmail.tsx b/packages/web/src/emails/magicLinkEmail.tsx index 778b60a3..0866446f 100644 --- a/packages/web/src/emails/magicLinkEmail.tsx +++ b/packages/web/src/emails/magicLinkEmail.tsx @@ -9,14 +9,13 @@ import { Tailwind, Text, } from '@react-email/components'; +import { SOURCEBOT_LOGO_LIGHT_LARGE_URL } from './constants'; interface MagicLinkEmailProps { - baseUrl: string, token: string, } export const MagicLinkEmail = ({ - baseUrl, token, }: MagicLinkEmailProps) => ( @@ -27,7 +26,7 @@ export const MagicLinkEmail = ({
Sourcebot Logo => { console.log("Executing fetch_file_content"); @@ -14,7 +16,7 @@ export const fetchFileContent = async (pr_payload: sourcebot_pr_payload, filenam } console.log(JSON.stringify(fileSourceRequest, null, 2)); - const response = await getFileSource(fileSourceRequest, "~"); + const response = await getFileSource(fileSourceRequest, "~", env.REVIEW_AGENT_API_KEY); if (isServiceError(response)) { throw new Error(`Failed to fetch file content for ${filename} from ${repoPath}: ${response.message}`); } diff --git a/packages/web/src/features/codeNav/actions.ts b/packages/web/src/features/codeNav/actions.ts new file mode 100644 index 00000000..92ea01ff --- /dev/null +++ b/packages/web/src/features/codeNav/actions.ts @@ -0,0 +1,119 @@ +'use server'; + +import { sew, withAuth, withOrgMembership } from "@/actions"; +import { searchResponseSchema } from "@/features/search/schemas"; +import { search } from "@/features/search/searchApi"; +import { isServiceError } from "@/lib/utils"; +import { FindRelatedSymbolsResponse } from "./types"; +import { ServiceError } from "@/lib/serviceError"; +import { SearchResponse } from "../search/types"; +import { OrgRole } from "@sourcebot/db"; + +// The maximum number of matches to return from the search API. +const MAX_REFERENCE_COUNT = 1000; + +export const findSearchBasedSymbolReferences = async ( + props: { + symbolName: string, + language: string, + revisionName?: string, + }, + domain: string, +): Promise => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async () => { + const { + symbolName, + language, + revisionName = "HEAD", + } = props; + + const query = `\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)} case:yes`; + + const searchResult = await search({ + query, + matches: MAX_REFERENCE_COUNT, + contextLines: 0, + }, domain); + + if (isServiceError(searchResult)) { + return searchResult; + } + + return parseRelatedSymbolsSearchResponse(searchResult); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) +); + + +export const findSearchBasedSymbolDefinitions = async ( + props: { + symbolName: string, + language: string, + revisionName?: string, + }, + domain: string, +): Promise => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async () => { + const { + symbolName, + language, + revisionName = "HEAD", + } = props; + + const query = `sym:\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)}`; + + const searchResult = await search({ + query, + matches: MAX_REFERENCE_COUNT, + contextLines: 0, + }, domain); + + if (isServiceError(searchResult)) { + return searchResult; + } + + return parseRelatedSymbolsSearchResponse(searchResult); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) +); + +const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse) => { + const parser = searchResponseSchema.transform(async ({ files }) => ({ + stats: { + matchCount: searchResult.stats.matchCount, + }, + files: files.flatMap((file) => { + const chunks = file.chunks; + + return { + fileName: file.fileName.text, + repository: file.repository, + repositoryId: file.repositoryId, + webUrl: file.webUrl, + language: file.language, + matches: chunks.flatMap((chunk) => { + return chunk.matchRanges.map((range) => ({ + lineContent: chunk.content, + range: range, + })) + }) + } + }).filter((file) => file.matches.length > 0), + repositoryInfo: searchResult.repositoryInfo + })); + + return parser.parseAsync(searchResult); +} + +// Expands the language filter to include all variants of the language. +const getExpandedLanguageFilter = (language: string) => { + switch (language) { + case "TypeScript": + case "JavaScript": + case "JSX": + case "TSX": + return `(lang:TypeScript or lang:JavaScript or lang:JSX or lang:TSX)` + default: + return `lang:${language}` + } +} \ No newline at end of file diff --git a/packages/web/src/features/codeNav/schemas.ts b/packages/web/src/features/codeNav/schemas.ts new file mode 100644 index 00000000..03f20721 --- /dev/null +++ b/packages/web/src/features/codeNav/schemas.ts @@ -0,0 +1,20 @@ +import { rangeSchema, repositoryInfoSchema } from "../search/schemas"; +import { z } from "zod"; + +export const findRelatedSymbolsResponseSchema = z.object({ + stats: z.object({ + matchCount: z.number(), + }), + files: z.array(z.object({ + fileName: z.string(), + repository: z.string(), + repositoryId: z.number(), + webUrl: z.string().optional(), + language: z.string(), + matches: z.array(z.object({ + lineContent: z.string(), + range: rangeSchema, + })) + })), + repositoryInfo: z.array(repositoryInfoSchema), +}); \ No newline at end of file diff --git a/packages/web/src/features/codeNav/types.ts b/packages/web/src/features/codeNav/types.ts new file mode 100644 index 00000000..bb9a282b --- /dev/null +++ b/packages/web/src/features/codeNav/types.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +import { findRelatedSymbolsResponseSchema } from "./schemas"; + +export type FindRelatedSymbolsResponse = z.infer; diff --git a/packages/web/src/features/entitlements/constants.ts b/packages/web/src/features/entitlements/constants.ts index 665b1f17..dfb71eba 100644 --- a/packages/web/src/features/entitlements/constants.ts +++ b/packages/web/src/features/entitlements/constants.ts @@ -4,6 +4,8 @@ const planLabels = { oss: "OSS", "cloud:team": "Team", "self-hosted:enterprise": "Enterprise (Self-Hosted)", + "self-hosted:enterprise-unlimited": "Enterprise (Self-Hosted) Unlimited", + "self-hosted:enterprise-custom": "Enterprise (Self-Hosted) Custom", } as const; export type Plan = keyof typeof planLabels; @@ -11,12 +13,22 @@ export type Plan = keyof typeof planLabels; // eslint-disable-next-line @typescript-eslint/no-unused-vars const entitlements = [ "search-contexts", - "billing" + "billing", + "public-access", + "multi-tenancy", + "sso", + "code-nav" ] as const; export type Entitlement = (typeof entitlements)[number]; +export const isValidEntitlement = (entitlement: string): entitlement is Entitlement => { + return entitlements.includes(entitlement as Entitlement); +} + export const entitlementsByPlan: Record = { oss: [], - "cloud:team": ["billing"], - "self-hosted:enterprise": ["search-contexts"], + "cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"], + "self-hosted:enterprise": ["search-contexts", "sso", "code-nav"], + "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav"], + "self-hosted:enterprise-custom": [], } as const; diff --git a/packages/web/src/features/entitlements/planProvider.tsx b/packages/web/src/features/entitlements/planProvider.tsx index 728eccc7..6b4257f3 100644 --- a/packages/web/src/features/entitlements/planProvider.tsx +++ b/packages/web/src/features/entitlements/planProvider.tsx @@ -1,19 +1,19 @@ 'use client'; import { createContext } from "react"; -import { Plan } from "./constants"; +import { Entitlement } from "./constants"; -export const PlanContext = createContext('oss'); +export const PlanContext = createContext<{ entitlements: Entitlement[] }>({ entitlements: [] }); interface PlanProviderProps { children: React.ReactNode; - plan: Plan; + entitlements: Entitlement[]; } -export const PlanProvider = ({ children, plan }: PlanProviderProps) => { +export const PlanProvider = ({ children, entitlements }: PlanProviderProps) => { return ( {children} diff --git a/packages/web/src/features/entitlements/server.ts b/packages/web/src/features/entitlements/server.ts index 9e08df4c..64f61fab 100644 --- a/packages/web/src/features/entitlements/server.ts +++ b/packages/web/src/features/entitlements/server.ts @@ -1,53 +1,100 @@ import { env } from "@/env.mjs" -import { Entitlement, entitlementsByPlan, Plan } from "./constants" +import { Entitlement, entitlementsByPlan, Plan, isValidEntitlement } from "./constants" import { base64Decode } from "@/lib/utils"; import { z } from "zod"; import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; const eeLicenseKeyPrefix = "sourcebot_ee_"; +export const SOURCEBOT_UNLIMITED_SEATS = -1; const eeLicenseKeyPayloadSchema = z.object({ id: z.string(), + seats: z.number(), // ISO 8601 date string - expiryDate: z.string().datetime().optional(), + expiryDate: z.string().datetime(), + customEntitlements: z.array(z.string()).optional() }); -const decodeLicenseKeyPayload = (payload: string) => { - const decodedPayload = base64Decode(payload); - const payloadJson = JSON.parse(decodedPayload); - return eeLicenseKeyPayloadSchema.parse(payloadJson); -} +type LicenseKeyPayload = z.infer; -export const getPlan = (): Plan => { - if (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT) { - return "cloud:team"; +const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => { + try { + const decodedPayload = base64Decode(payload); + const payloadJson = JSON.parse(decodedPayload); + return eeLicenseKeyPayloadSchema.parse(payloadJson); + } catch (error) { + console.error(`Failed to decode license key payload: ${error}`); + process.exit(1); } +} +export const getLicenseKey = (): LicenseKeyPayload | null => { const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY; if (licenseKey && licenseKey.startsWith(eeLicenseKeyPrefix)) { const payload = licenseKey.substring(eeLicenseKeyPrefix.length); + return decodeLicenseKeyPayload(payload); + } + return null; +} - try { - const { expiryDate } = decodeLicenseKeyPayload(payload); +export const getPlan = (): Plan => { + if (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT) { + return "cloud:team"; + } - if (expiryDate && new Date(expiryDate).getTime() < new Date().getTime()) { - console.error(`The provided license key has expired. Falling back to oss plan. Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); - return "oss"; - } + const licenseKey = getLicenseKey(); + if (licenseKey) { + const expiryDate = new Date(licenseKey.expiryDate); + if (expiryDate.getTime() < new Date().getTime()) { + console.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Falling back to oss plan. Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); + process.exit(1); + } - return "self-hosted:enterprise"; - } catch (error) { - console.error(`Failed to decode license key payload with error: ${error}`); - console.info('Falling back to oss plan.'); - return "oss"; + if (licenseKey.customEntitlements) { + return "self-hosted:enterprise-custom"; } + return licenseKey.seats === SOURCEBOT_UNLIMITED_SEATS ? "self-hosted:enterprise-unlimited" : "self-hosted:enterprise"; + } else { + console.info(`No valid license key found. Falling back to oss plan.`); + return "oss"; } +} - return "oss"; +export const getSeats = (): number => { + const licenseKey = getLicenseKey(); + return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS; } export const hasEntitlement = (entitlement: Entitlement) => { - const plan = getPlan(); - const entitlements = entitlementsByPlan[plan]; + const entitlements = getEntitlements(); return entitlements.includes(entitlement); } + +export const getEntitlements = (): Entitlement[] => { + const licenseKey = getLicenseKey(); + if (!licenseKey) { + return entitlementsByPlan["oss"]; + } + + const plan = getPlan(); + if (plan === "self-hosted:enterprise-custom") { + const customEntitlements = licenseKey.customEntitlements; + if (!customEntitlements) { + console.error(`The provided license key is under the self-hosted:enterprise-custom plan but has no custom entitlements. Returning oss entitlements.`); + return entitlementsByPlan["oss"]; + } + + const validCustomEntitlements: Entitlement[] = []; + for (const entitlement of customEntitlements) { + if (!isValidEntitlement(entitlement)) { + console.error(`Invalid custom entitlement "${entitlement}" provided in license key. Skipping.`); + continue; + } + validCustomEntitlements.push(entitlement as Entitlement); + } + + return validCustomEntitlements; + } + + return entitlementsByPlan[plan]; +} diff --git a/packages/web/src/features/entitlements/useHasEntitlement.ts b/packages/web/src/features/entitlements/useHasEntitlement.ts index c629c2c9..86cb1ce3 100644 --- a/packages/web/src/features/entitlements/useHasEntitlement.ts +++ b/packages/web/src/features/entitlements/useHasEntitlement.ts @@ -1,10 +1,10 @@ 'use client'; -import { Entitlement, entitlementsByPlan } from "./constants"; -import { usePlan } from "./usePlan"; +import { Entitlement } from "./constants"; +import { useContext } from "react"; +import { PlanContext } from "./planProvider"; export const useHasEntitlement = (entitlement: Entitlement) => { - const plan = usePlan(); - const entitlements = entitlementsByPlan[plan]; + const { entitlements } = useContext(PlanContext); return entitlements.includes(entitlement); } \ No newline at end of file diff --git a/packages/web/src/features/entitlements/usePlan.ts b/packages/web/src/features/entitlements/usePlan.ts deleted file mode 100644 index 126c060e..00000000 --- a/packages/web/src/features/entitlements/usePlan.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useContext } from "react"; -import { PlanContext } from "./planProvider"; - -export const usePlan = () => { - const plan = useContext(PlanContext); - return plan; -} \ No newline at end of file diff --git a/packages/web/src/features/search/fileSourceApi.ts b/packages/web/src/features/search/fileSourceApi.ts index 131ff881..d285aaa9 100644 --- a/packages/web/src/features/search/fileSourceApi.ts +++ b/packages/web/src/features/search/fileSourceApi.ts @@ -1,16 +1,18 @@ +'use server'; + import escapeStringRegexp from "escape-string-regexp"; import { fileNotFound, ServiceError } from "../../lib/serviceError"; import { FileSourceRequest, FileSourceResponse } from "./types"; import { isServiceError } from "../../lib/utils"; import { search } from "./searchApi"; import { sew, withAuth, withOrgMembership } from "@/actions"; - +import { OrgRole } from "@sourcebot/db"; // @todo (bkellam) : We should really be using `git show :` to fetch file contents here. // This will allow us to support permalinks to files at a specific revision that may not be indexed // by zoekt. -export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async () => { +export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async () => { const escapedFileName = escapeStringRegexp(fileName); const escapedRepository = escapeStringRegexp(repository); @@ -23,7 +25,7 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource query, matches: 1, whole: true, - }, domain); + }, domain, apiKey); if (isServiceError(searchResponse)) { return searchResponse; @@ -41,6 +43,7 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource return { source, language, + webUrl: file.webUrl, } satisfies FileSourceResponse; - }), /* allowSingleTenantUnauthedAccess = */ true) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) ); diff --git a/packages/web/src/features/search/listReposApi.ts b/packages/web/src/features/search/listReposApi.ts index 9077d166..296e1238 100644 --- a/packages/web/src/features/search/listReposApi.ts +++ b/packages/web/src/features/search/listReposApi.ts @@ -1,12 +1,13 @@ +import { OrgRole } from "@sourcebot/db"; import { invalidZoektResponse, ServiceError } from "../../lib/serviceError"; import { ListRepositoriesResponse } from "./types"; import { zoektFetch } from "./zoektClient"; import { zoektListRepositoriesResponseSchema } from "./zoektSchema"; import { sew, withAuth, withOrgMembership } from "@/actions"; -export const listRepositories = async (domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { +export const listRepositories = async (domain: string, apiKey: string | undefined = undefined): Promise => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const body = JSON.stringify({ opts: { Field: 0, @@ -15,7 +16,7 @@ export const listRepositories = async (domain: string): Promise = {}; header = { - "X-Tenant-ID": orgId.toString() + "X-Tenant-ID": org.id.toString() }; const listResponse = await zoektFetch({ @@ -42,5 +43,5 @@ export const listRepositories = async (domain: string): Promise sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const transformedQuery = await transformZoektQuery(query, orgId); +export const search = async ({ query, matches, contextLines, whole }: SearchRequest, domain: string, apiKey: string | undefined = undefined) => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const transformedQuery = await transformZoektQuery(query, org.id); if (isServiceError(transformedQuery)) { return transformedQuery; } @@ -160,7 +160,7 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ let header: Record = {}; header = { - "X-Tenant-ID": orgId.toString() + "X-Tenant-ID": org.id.toString() }; const searchResponse = await zoektFetch({ @@ -200,7 +200,7 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ id: { in: Array.from(repoIdentifiers).filter((id) => typeof id === "number"), }, - orgId, + orgId: org.id, } })).forEach(repo => repos.set(repo.id, repo)); @@ -209,10 +209,96 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ name: { in: Array.from(repoIdentifiers).filter((id) => typeof id === "string"), }, - orgId, + orgId: org.id, } })).forEach(repo => repos.set(repo.name, repo)); + const files = Result.Files?.map((file) => { + const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName); + + const webUrl = (() => { + const template: string | undefined = Result.RepoURLs[file.Repository]; + if (!template) { + return undefined; + } + + // If there are multiple branches pointing to the same revision of this file, it doesn't + // matter which branch we use here, so use the first one. + const branch = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD"; + return getFileWebUrl(template, branch, file.FileName); + })(); + + const identifier = file.RepositoryID ?? file.Repository; + const repo = repos.get(identifier); + + // This should never happen... but if it does, we skip the file. + if (!repo) { + Sentry.captureMessage( + `Repository not found for identifier: ${identifier}; skipping file "${file.FileName}"`, + 'warning' + ); + return undefined; + } + + return { + fileName: { + text: file.FileName, + matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({ + start: { + byteOffset: range.Start.ByteOffset, + column: range.Start.Column, + lineNumber: range.Start.LineNumber, + }, + end: { + byteOffset: range.End.ByteOffset, + column: range.End.Column, + lineNumber: range.End.LineNumber, + } + })) : [], + }, + repository: repo.name, + repositoryId: repo.id, + webUrl: webUrl, + language: file.Language, + chunks: file.ChunkMatches + .filter((chunk) => !chunk.FileName) // Filter out filename chunks. + .map((chunk) => { + return { + content: chunk.Content, + matchRanges: chunk.Ranges.map((range) => ({ + start: { + byteOffset: range.Start.ByteOffset, + column: range.Start.Column, + lineNumber: range.Start.LineNumber, + }, + end: { + byteOffset: range.End.ByteOffset, + column: range.End.Column, + lineNumber: range.End.LineNumber, + } + }) satisfies SourceRange), + contentStart: { + byteOffset: chunk.ContentStart.ByteOffset, + column: chunk.ContentStart.Column, + lineNumber: chunk.ContentStart.LineNumber, + }, + symbols: chunk.SymbolInfo?.map((symbol) => { + return { + symbol: symbol.Sym, + kind: symbol.Kind, + parent: symbol.Parent.length > 0 ? { + symbol: symbol.Parent, + kind: symbol.ParentKind, + } : undefined, + } + }) ?? undefined, + } + }), + branches: file.Branches, + content: file.Content, + } + }).filter((file) => file !== undefined) ?? []; + return { zoektStats: { duration: Result.Duration, @@ -236,91 +322,7 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ regexpsConsidered: Result.RegexpsConsidered, flushReason: Result.FlushReason, }, - files: Result.Files?.map((file) => { - const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName); - - const webUrl = (() => { - const template: string | undefined = Result.RepoURLs[file.Repository]; - if (!template) { - return undefined; - } - - // If there are multiple branches pointing to the same revision of this file, it doesn't - // matter which branch we use here, so use the first one. - const branch = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD"; - return getFileWebUrl(template, branch, file.FileName); - })(); - - const identifier = file.RepositoryID ?? file.Repository; - const repo = repos.get(identifier); - - // This should never happen... but if it does, we skip the file. - if (!repo) { - Sentry.captureMessage( - `Repository not found for identifier: ${identifier}; skipping file "${file.FileName}"`, - 'warning' - ); - return undefined; - } - - return { - fileName: { - text: file.FileName, - matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({ - start: { - byteOffset: range.Start.ByteOffset, - column: range.Start.Column, - lineNumber: range.Start.LineNumber, - }, - end: { - byteOffset: range.End.ByteOffset, - column: range.End.Column, - lineNumber: range.End.LineNumber, - } - })) : [], - }, - repository: repo.name, - repositoryId: repo.id, - webUrl: webUrl, - language: file.Language, - chunks: file.ChunkMatches - .filter((chunk) => !chunk.FileName) // Filter out filename chunks. - .map((chunk) => { - return { - content: chunk.Content, - matchRanges: chunk.Ranges.map((range) => ({ - start: { - byteOffset: range.Start.ByteOffset, - column: range.Start.Column, - lineNumber: range.Start.LineNumber, - }, - end: { - byteOffset: range.End.ByteOffset, - column: range.End.Column, - lineNumber: range.End.LineNumber, - } - }) satisfies SearchResultRange), - contentStart: { - byteOffset: chunk.ContentStart.ByteOffset, - column: chunk.ContentStart.Column, - lineNumber: chunk.ContentStart.LineNumber, - }, - symbols: chunk.SymbolInfo?.map((symbol) => { - return { - symbol: symbol.Sym, - kind: symbol.Kind, - parent: symbol.Parent.length > 0 ? { - symbol: symbol.Parent, - kind: symbol.ParentKind, - } : undefined, - } - }) ?? undefined, - } - }), - branches: file.Branches, - content: file.Content, - } - }).filter((file) => file !== undefined) ?? [], + files, repositoryInfo: Array.from(repos.values()).map((repo) => ({ id: repo.id, codeHostType: repo.external_codeHostType, @@ -329,9 +331,19 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ webUrl: repo.webUrl ?? undefined, })), isBranchFilteringEnabled: isBranchFilteringEnabled, + stats: { + matchCount: files.reduce( + (acc, file) => + acc + file.chunks.reduce( + (acc, chunk) => acc + chunk.matchRanges.length, + 0, + ), + 0, + ) + } } satisfies SearchResponse; }); return parser.parseAsync(searchBody); - }), /* allowSingleTenantUnauthedAccess = */ true) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) ) diff --git a/packages/web/src/features/search/types.ts b/packages/web/src/features/search/types.ts index 1e2d331d..5271b94b 100644 --- a/packages/web/src/features/search/types.ts +++ b/packages/web/src/features/search/types.ts @@ -14,7 +14,6 @@ import { z } from "zod"; export type SearchRequest = z.infer; export type SearchResponse = z.infer; -export type SearchResultRange = z.infer; export type SearchResultLocation = z.infer; export type SearchResultFile = SearchResponse["files"][number]; export type SearchResultChunk = SearchResultFile["chunks"][number]; @@ -26,4 +25,5 @@ export type Repository = ListRepositoriesResponse["repos"][number]; export type FileSourceRequest = z.infer; export type FileSourceResponse = z.infer; -export type RepositoryInfo = z.infer; \ No newline at end of file +export type RepositoryInfo = z.infer; +export type SourceRange = z.infer; \ No newline at end of file diff --git a/packages/web/src/hooks/useCodeMirrorHighlighter.ts b/packages/web/src/hooks/useCodeMirrorHighlighter.ts new file mode 100644 index 00000000..0c0b944f --- /dev/null +++ b/packages/web/src/hooks/useCodeMirrorHighlighter.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useMemo } from "react"; +import { tags as t, tagHighlighter } from '@lezer/highlight'; + +export const useCodeMirrorHighlighter = () => { + return useMemo(() => { + return tagHighlighter([ + // Keywords (if, for, class, etc.) + { tag: t.keyword, class: 'text-editor-tag-keyword' }, + + // Names, identifiers, properties + { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName, t.variableName], class: 'text-editor-tag-name' }, + + // Functions and variable definitions + { tag: [t.function(t.variableName), t.definition(t.variableName)], class: 'text-editor-tag-function' }, + { tag: t.local(t.variableName), class: 'text-editor-tag-variable-local' }, + + // Property definitions + { tag: [t.definition(t.name), t.separator, t.definition(t.propertyName)], class: 'text-editor-tag-definition' }, + + // Labels + { tag: [t.labelName], class: 'text-editor-tag-label' }, + + // Constants and standards + { tag: [t.color, t.constant(t.name), t.standard(t.name)], class: 'text-editor-tag-constant' }, + + // Braces and brackets + { tag: [t.brace], class: 'text-editor-tag-brace' }, + { tag: [t.squareBracket], class: 'text-editor-tag-bracket-square' }, + { tag: [t.angleBracket], class: 'text-editor-tag-bracket-angle' }, + + // Types and classes + { tag: [t.typeName, t.namespace], class: 'text-editor-tag-type' }, + { tag: [t.className], class: 'text-editor-tag-tag' }, + + // Numbers and annotations + { tag: [t.number, t.changed, t.modifier, t.self], class: 'text-editor-tag-number' }, + { tag: [t.annotation], class: 'text-editor-tag-annotation-special' }, + + // Operators + { tag: [t.operator, t.operatorKeyword], class: 'text-editor-tag-operator' }, + + // HTML/XML tags and attributes + { tag: [t.tagName], class: 'text-editor-tag-tag' }, + { tag: [t.attributeName], class: 'text-editor-tag-attribute' }, + + // Strings and quotes + { tag: [t.string], class: 'text-editor-tag-string' }, + { tag: [t.quote], class: 'text-editor-tag-quote' }, + { tag: [t.processingInstruction, t.inserted], class: 'text-editor-tag-processing' }, + + // Special string content + { tag: [t.url, t.escape, t.special(t.string)], class: 'text-editor-tag-string' }, + { tag: [t.regexp], class: 'text-editor-tag-constant' }, + + // Links + { tag: t.link, class: 'text-editor-tag-link underline' }, + + // Meta and comments + { tag: [t.meta], class: 'text-editor-tag-meta' }, + { tag: [t.comment], class: 'text-editor-tag-comment italic' }, + + // Text formatting + { tag: t.strong, class: 'text-editor-tag-emphasis font-bold' }, + { tag: t.emphasis, class: 'text-editor-tag-emphasis italic' }, + { tag: t.strikethrough, class: 'text-editor-tag-emphasis line-through' }, + + // Headings + { tag: t.heading, class: 'text-editor-tag-heading font-bold' }, + { tag: t.special(t.heading1), class: 'text-editor-tag-heading font-bold' }, + { tag: t.heading1, class: 'text-editor-tag-heading font-bold' }, + { tag: [t.heading2, t.heading3, t.heading4], class: 'text-editor-tag-heading font-bold' }, + { tag: [t.heading5, t.heading6], class: 'text-editor-tag-heading' }, + + // Atoms and booleans + { tag: [t.atom, t.bool, t.special(t.variableName)], class: 'text-editor-tag-atom' }, + + // Content separator + { tag: [t.contentSeparator], class: 'text-editor-tag-separator' }, + + // Invalid syntax + { tag: t.invalid, class: 'text-editor-tag-invalid' } + ]); + }, []); +} diff --git a/packages/web/src/hooks/useSyntaxHighlightingExtension.ts b/packages/web/src/hooks/useCodeMirrorLanguageExtension.ts similarity index 89% rename from packages/web/src/hooks/useSyntaxHighlightingExtension.ts rename to packages/web/src/hooks/useCodeMirrorLanguageExtension.ts index 053e9b7b..b77ca321 100644 --- a/packages/web/src/hooks/useSyntaxHighlightingExtension.ts +++ b/packages/web/src/hooks/useCodeMirrorLanguageExtension.ts @@ -4,7 +4,7 @@ import { EditorView } from "@codemirror/view"; import { useExtensionWithDependency } from "./useExtensionWithDependency"; import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage"; -export const useSyntaxHighlightingExtension = (linguistLanguage: string, view: EditorView | undefined) => { +export const useCodeMirrorLanguageExtension = (linguistLanguage: string, view: EditorView | undefined) => { const extension = useExtensionWithDependency( view ?? null, () => { diff --git a/packages/web/src/hooks/useCodeMirrorTheme.ts b/packages/web/src/hooks/useCodeMirrorTheme.ts index e7ee97df..6bbe8f07 100644 --- a/packages/web/src/hooks/useCodeMirrorTheme.ts +++ b/packages/web/src/hooks/useCodeMirrorTheme.ts @@ -1,78 +1,154 @@ 'use client'; -import { useTailwind } from "./useTailwind"; import { useMemo } from "react"; import { useThemeNormalized } from "./useThemeNormalized"; -import createTheme from "@uiw/codemirror-themes"; -import { defaultLightThemeOption } from "@uiw/react-codemirror"; -import { tags as t } from '@lezer/highlight'; +import { + useCodeMirrorHighlighter, +} from "./useCodeMirrorHighlighter"; +import { EditorView } from "@codemirror/view"; import { syntaxHighlighting } from "@codemirror/language"; -import { defaultHighlightStyle } from "@codemirror/language"; - -// From: https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts -const chalky = "#e5c07b", - coral = "#e06c75", - cyan = "#56b6c2", - invalid = "#ffffff", - ivory = "#abb2bf", - stone = "#7d8799", - malibu = "#61afef", - sage = "#98c379", - whiskey = "#d19a66", - violet = "#c678dd", - highlightBackground = "#2c313aaa", - background = "#282c34", - selection = "#3E4451", - cursor = "#528bff"; - +import type { StyleSpec } from 'style-mod'; +import { Extension } from "@codemirror/state"; +import tailwind from "@/tailwind"; export const useCodeMirrorTheme = () => { - const tailwind = useTailwind(); const { theme } = useThemeNormalized(); - - const darkTheme = useMemo(() => { - return createTheme({ - theme: 'dark', - settings: { - background: tailwind.theme.colors.background, - foreground: ivory, - caret: cursor, - selection: selection, - selectionMatch: "#aafe661a", // for matching selections - gutterBackground: background, - gutterForeground: stone, - gutterBorder: 'none', - gutterActiveForeground: ivory, - lineHighlight: highlightBackground, - }, - styles: [ - { tag: t.comment, color: stone }, - { tag: t.keyword, color: violet }, - { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: coral }, - { tag: [t.function(t.variableName), t.labelName], color: malibu }, - { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: whiskey }, - { tag: [t.definition(t.name), t.separator], color: ivory }, - { tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: chalky }, - { tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], color: cyan }, - { tag: [t.meta], color: stone }, - { tag: t.strong, fontWeight: 'bold' }, - { tag: t.emphasis, fontStyle: 'italic' }, - { tag: t.strikethrough, textDecoration: 'line-through' }, - { tag: t.link, color: stone, textDecoration: 'underline' }, - { tag: t.heading, fontWeight: 'bold', color: coral }, - { tag: [t.atom, t.bool, t.special(t.variableName)], color: whiskey }, - { tag: [t.processingInstruction, t.string, t.inserted], color: sage }, - { tag: t.invalid, color: invalid } - ] - }); - }, [tailwind.theme.colors.background]); + const highlightStyle = useCodeMirrorHighlighter(); const cmTheme = useMemo(() => { - return theme === 'dark' ? darkTheme : [ - defaultLightThemeOption, - syntaxHighlighting(defaultHighlightStyle), + const { + background, + foreground, + caret, + selection, + selectionMatch, + gutterBackground, + gutterForeground, + gutterBorder, + gutterActiveForeground, + lineHighlight, + } = tailwind.theme.colors.editor; + + return [ + createThemeExtension({ + theme: theme === 'dark' ? 'dark' : 'light', + settings: { + background, + foreground, + caret, + selection, + selectionMatch, + gutterBackground, + gutterForeground, + gutterBorder, + gutterActiveForeground, + lineHighlight, + fontFamily: tailwind.theme.fontFamily.editor, + fontSize: tailwind.theme.fontSize.editor, + } + }), + syntaxHighlighting(highlightStyle) ] - }, [theme, darkTheme]); + }, [highlightStyle, theme]); return cmTheme; +} + + +// @see: https://github.com/uiwjs/react-codemirror/blob/e365f7d1f8a0ec2cd88455b7a248f6338c859cc7/themes/theme/src/index.tsx +const createThemeExtension = ({ theme, settings = {} }: CreateThemeOptions): Extension => { + const themeOptions: Record = { + '.cm-gutters': {}, + }; + const baseStyle: StyleSpec = {}; + if (settings.background) { + baseStyle.backgroundColor = settings.background; + } + if (settings.backgroundImage) { + baseStyle.backgroundImage = settings.backgroundImage; + } + if (settings.foreground) { + baseStyle.color = settings.foreground; + } + if (settings.fontSize) { + baseStyle.fontSize = settings.fontSize; + } + if (settings.background || settings.foreground) { + themeOptions['&'] = baseStyle; + } + + if (settings.fontFamily) { + themeOptions['&.cm-editor .cm-scroller'] = { + fontFamily: settings.fontFamily, + }; + } + if (settings.gutterBackground) { + themeOptions['.cm-gutters'].backgroundColor = settings.gutterBackground; + } + if (settings.gutterForeground) { + themeOptions['.cm-gutters'].color = settings.gutterForeground; + } + if (settings.gutterBorder) { + themeOptions['.cm-gutters'].borderRightColor = settings.gutterBorder; + } + + if (settings.caret) { + themeOptions['.cm-content'] = { + caretColor: settings.caret, + }; + themeOptions['.cm-cursor, .cm-dropCursor'] = { + borderLeftColor: settings.caret, + }; + } + + const activeLineGutterStyle: StyleSpec = {}; + if (settings.gutterActiveForeground) { + activeLineGutterStyle.color = settings.gutterActiveForeground; + } + if (settings.lineHighlight) { + themeOptions['.cm-activeLine'] = { + backgroundColor: settings.lineHighlight, + }; + activeLineGutterStyle.backgroundColor = settings.lineHighlight; + } + themeOptions['.cm-activeLineGutter'] = activeLineGutterStyle; + + if (settings.selection) { + themeOptions[ + '&.cm-focused .cm-selectionBackground, & .cm-line::selection, & .cm-selectionLayer .cm-selectionBackground, .cm-content ::selection' + ] = { + background: settings.selection + ' !important', + }; + } + if (settings.selectionMatch) { + themeOptions['& .cm-selectionMatch'] = { + backgroundColor: settings.selectionMatch, + }; + } + const themeExtension = EditorView.theme(themeOptions, { + dark: theme === 'dark', + }); + + return themeExtension; +}; + +interface CreateThemeOptions { + theme: 'light' | 'dark'; + settings: Settings; +} + +interface Settings { + background?: string; + backgroundImage?: string; + foreground?: string; + caret?: string; + selection?: string; + selectionMatch?: string; + lineHighlight?: string; + gutterBackground?: string; + gutterForeground?: string; + gutterActiveForeground?: string; + gutterBorder?: string; + fontFamily?: string; + fontSize?: StyleSpec['fontSize']; } \ No newline at end of file diff --git a/packages/web/src/hooks/useTailwind.ts b/packages/web/src/hooks/useTailwind.ts deleted file mode 100644 index c6d05eb4..00000000 --- a/packages/web/src/hooks/useTailwind.ts +++ /dev/null @@ -1,13 +0,0 @@ -'use client'; - -import { useMemo } from "react"; -import resolveConfig from 'tailwindcss/resolveConfig'; -import tailwindConfig from '../../tailwind.config'; - -export const useTailwind = () => { - const tailwind = useMemo(() => { - return resolveConfig(tailwindConfig); - }, []); - - return tailwind; -} \ No newline at end of file diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index b9fedb30..545ef955 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -1,7 +1,7 @@ import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus } from '@sourcebot/db'; import { env } from './env.mjs'; import { prisma } from "@/prisma"; -import { SINGLE_TENANT_USER_ID, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_NAME, SINGLE_TENANT_USER_EMAIL } from './lib/constants'; +import { SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_NAME, SOURCEBOT_GUEST_USER_ID } from './lib/constants'; import { readFile } from 'fs/promises'; import { watch } from 'fs'; import stripJsonComments from 'strip-json-comments'; @@ -10,15 +10,16 @@ import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { indexSchema } from '@sourcebot/schemas/v3/index.schema'; import Ajv from 'ajv'; import { syncSearchContexts } from '@/ee/features/searchContexts/syncSearchContexts'; +import { hasEntitlement } from '@/features/entitlements/server'; +import { createGuestUser, setPublicAccessStatus } from '@/ee/features/publicAccess/publicAccess'; +import { isServiceError } from './lib/utils'; +import { ServiceErrorException } from './lib/serviceError'; +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; const ajv = new Ajv({ validateFormats: false, }); -if (env.SOURCEBOT_AUTH_ENABLED === 'false' && env.SOURCEBOT_TENANCY_MODE === 'multi') { - throw new Error('SOURCEBOT_AUTH_ENABLED must be true when SOURCEBOT_TENANCY_MODE is multi'); -} - const isRemotePath = (path: string) => { return path.startsWith('https://') || path.startsWith('http://'); } @@ -112,7 +113,7 @@ const syncConnections = async (connections?: { [key: string]: ConnectionConfig } } } -const syncDeclarativeConfig = async (configPath: string) => { +const readConfig = async (configPath: string): Promise => { const configContent = await (async () => { if (isRemotePath(configPath)) { const response = await fetch(configPath); @@ -132,11 +133,56 @@ const syncDeclarativeConfig = async (configPath: string) => { if (!isValidConfig) { throw new Error(`Config file '${configPath}' is invalid: ${ajv.errorsText(ajv.errors)}`); } + return config; +} + +const syncDeclarativeConfig = async (configPath: string) => { + const config = await readConfig(configPath); + + const hasPublicAccessEntitlement = hasEntitlement("public-access"); + const enablePublicAccess = config.settings?.enablePublicAccess; + if (enablePublicAccess !== undefined && !hasPublicAccessEntitlement) { + console.error(`Public access flag is set in the config file but your license doesn't have public access entitlement. Please contact ${SOURCEBOT_SUPPORT_EMAIL} to request a license upgrade.`); + process.exit(1); + } + + if (hasPublicAccessEntitlement) { + console.log(`Setting public access status to ${!!enablePublicAccess} for org ${SINGLE_TENANT_ORG_DOMAIN}`); + const res = await setPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN, !!enablePublicAccess); + if (isServiceError(res)) { + throw new ServiceErrorException(res); + } + } await syncConnections(config.connections); await syncSearchContexts(config.contexts); } +const pruneOldGuestUser = async () => { + // The old guest user doesn't have the GUEST role + const guestUser = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId: SINGLE_TENANT_ORG_ID, + userId: SOURCEBOT_GUEST_USER_ID, + }, + role: { + not: OrgRole.GUEST, + } + }, + }); + + if (guestUser) { + await prisma.user.delete({ + where: { + id: guestUser.userId, + }, + }); + + console.log(`Deleted old guest user ${guestUser.userId}`); + } +} + const initSingleTenancy = async () => { await prisma.org.upsert({ where: { @@ -146,54 +192,37 @@ const initSingleTenancy = async () => { create: { name: SINGLE_TENANT_ORG_NAME, domain: SINGLE_TENANT_ORG_DOMAIN, - id: SINGLE_TENANT_ORG_ID, - isOnboarded: env.SOURCEBOT_AUTH_ENABLED === 'false', + id: SINGLE_TENANT_ORG_ID } }); - if (env.SOURCEBOT_AUTH_ENABLED === 'false') { - // Default user for single tenancy unauthed access - await prisma.user.upsert({ - where: { - id: SINGLE_TENANT_USER_ID, - }, - update: {}, - create: { - id: SINGLE_TENANT_USER_ID, - email: SINGLE_TENANT_USER_EMAIL, - }, - }); + // This is needed because v4 introduces the GUEST org role as well as making authentication required. + // To keep things simple, we'll just delete the old guest user if it exists in the DB + await pruneOldGuestUser(); + + const hasPublicAccessEntitlement = hasEntitlement("public-access"); + if (hasPublicAccessEntitlement) { + const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN); + if (isServiceError(res)) { + throw new ServiceErrorException(res); + } + } + + // Load any connections defined declaratively in the config file. + const configPath = env.CONFIG_PATH; + if (configPath) { + await syncDeclarativeConfig(configPath); + // If we're given a config file, mark the org as onboarded so we don't go through + // the UI conneciton onboarding flow await prisma.org.update({ where: { id: SINGLE_TENANT_ORG_ID, }, data: { - members: { - upsert: { - where: { - orgId_userId: { - orgId: SINGLE_TENANT_ORG_ID, - userId: SINGLE_TENANT_USER_ID, - } - }, - update: {}, - create: { - role: OrgRole.MEMBER, - user: { - connect: { id: SINGLE_TENANT_USER_ID } - } - } - } - } + isOnboarded: true, } }); - } - - // Load any connections defined declaratively in the config file. - const configPath = env.CONFIG_PATH; - if (configPath) { - await syncDeclarativeConfig(configPath); // watch for changes assuming it is a local file if (!isRemotePath(configPath)) { @@ -205,8 +234,20 @@ const initSingleTenancy = async () => { } } +const initMultiTenancy = async () => { + const hasMultiTenancyEntitlement = hasEntitlement("multi-tenancy"); + if (!hasMultiTenancyEntitlement) { + console.error(`SOURCEBOT_TENANCY_MODE is set to ${env.SOURCEBOT_TENANCY_MODE} but your license doesn't have multi-tenancy entitlement. Please contact ${SOURCEBOT_SUPPORT_EMAIL} to request a license upgrade.`); + process.exit(1); + } +} + (async () => { if (env.SOURCEBOT_TENANCY_MODE === 'single') { await initSingleTenancy(); + } else if (env.SOURCEBOT_TENANCY_MODE === 'multi') { + await initMultiTenancy(); + } else { + throw new Error(`Invalid SOURCEBOT_TENANCY_MODE: ${env.SOURCEBOT_TENANCY_MODE}`); } })(); diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts index 88913335..717b3714 100644 --- a/packages/web/src/lib/constants.ts +++ b/packages/web/src/lib/constants.ts @@ -24,8 +24,10 @@ export const TEAM_FEATURES = [ export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed'; -export const SINGLE_TENANT_USER_ID = '1'; -export const SINGLE_TENANT_USER_EMAIL = 'default@sourcebot.dev'; +// NOTE: changing SOURCEBOT_GUEST_USER_ID may break backwards compatibility since this value is used +// to detect old guest users in the DB. If you change this value ensure it doesn't break upgrade flows +export const SOURCEBOT_GUEST_USER_ID = '1'; +export const SOURCEBOT_GUEST_USER_EMAIL = 'guest@sourcebot.dev'; export const SINGLE_TENANT_ORG_ID = 1; export const SINGLE_TENANT_ORG_DOMAIN = '~'; export const SINGLE_TENANT_ORG_NAME = 'default'; diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index f0d503ba..85cc2d08 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -24,4 +24,10 @@ export enum ErrorCode { ACTION_DISALLOWED_IN_TENANCY_MODE = 'ACTION_DISALLOWED_IN_TENANCY_MODE', SEARCH_CONTEXT_NOT_FOUND = 'SEARCH_CONTEXT_NOT_FOUND', MISSING_ORG_DOMAIN_HEADER = 'MISSING_ORG_DOMAIN_HEADER', + ORG_SEAT_COUNT_REACHED = 'ORG_SEAT_COUNT_REACHED', + CONNECTION_CONFIG_PATH_SET = 'CONNECTION_CONFIG_PATH_SET', + INVALID_ORG_METADATA = 'INVALID_ORG_METADATA', + API_KEY_ALREADY_EXISTS = 'API_KEY_ALREADY_EXISTS', + API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND', + INVALID_API_KEY = 'INVALID_API_KEY', } diff --git a/packages/web/src/lib/extensions/searchResultHighlightExtension.ts b/packages/web/src/lib/extensions/searchResultHighlightExtension.ts index 56325af3..3ae9e4a1 100644 --- a/packages/web/src/lib/extensions/searchResultHighlightExtension.ts +++ b/packages/web/src/lib/extensions/searchResultHighlightExtension.ts @@ -1,13 +1,13 @@ import { EditorSelection, Extension, StateEffect, StateField, Text, Transaction } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; -import { SearchResultRange } from "@/features/search/types"; +import { SourceRange } from "@/features/search/types"; const setMatchState = StateEffect.define<{ selectedMatchIndex: number, - ranges: SearchResultRange[], + ranges: SourceRange[], }>(); -const convertToCodeMirrorRange = (range: SearchResultRange, document: Text) => { +const convertToCodeMirrorRange = (range: SourceRange, document: Text) => { const { start, end } = range; const from = document.line(start.lineNumber).from + start.column - 1; const to = document.line(end.lineNumber).from + end.column - 1; @@ -46,13 +46,13 @@ const matchHighlighter = StateField.define({ }); const matchMark = Decoration.mark({ - class: "cm-searchMatch" + class: "searchMatch" }); const selectedMatchMark = Decoration.mark({ - class: "cm-searchMatch-selected" + class: "searchMatch-selected" }); -export const highlightRanges = (selectedMatchIndex: number, ranges: SearchResultRange[], view: EditorView) => { +export const highlightRanges = (selectedMatchIndex: number, ranges: SourceRange[], view: EditorView) => { const setState = setMatchState.of({ selectedMatchIndex, ranges, diff --git a/packages/web/src/lib/newsData.ts b/packages/web/src/lib/newsData.ts new file mode 100644 index 00000000..a03669a5 --- /dev/null +++ b/packages/web/src/lib/newsData.ts @@ -0,0 +1,22 @@ +import { NewsItem } from "./types"; + +export const newsData: NewsItem[] = [ + { + unique_id: "code-nav", + header: "Code navigation", + sub_header: "Built in go-to definition and find references", + url: "https://docs.sourcebot.dev/docs/search/code-navigation" + }, + { + unique_id: "sso", + header: "SSO", + sub_header: "We've added support for SSO providers", + url: "https://docs.sourcebot.dev/self-hosting/configuration/authentication", + }, + { + unique_id: "search-contexts", + header: "Search contexts", + sub_header: "Filter searches by groups of repos", + url: "https://docs.sourcebot.dev/docs/search/search-contexts" + } +]; \ No newline at end of file diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 3368ec3f..3f601c0d 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -250,6 +250,26 @@ export type PosthogEventMap = { $pageview: { $current_url: string, }, + ////////////////////////////////////////////////////////////////// + wa_requests_list_approve_request_success: {}, + wa_requests_list_approve_request_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_requests_list_reject_request_success: {}, + wa_requests_list_reject_request_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_api_key_created: {}, + wa_api_key_creation_fail: {}, + ////////////////////////////////////////////////////////////////// + wa_preview_panel_find_references_pressed: {}, + wa_preview_panel_goto_definition_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_browse_find_references_pressed: {}, + wa_browse_goto_definition_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_explore_menu_reference_clicked: {}, } - export type PosthogEvent = keyof PosthogEventMap; \ No newline at end of file diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts index 52a2b506..71132673 100644 --- a/packages/web/src/lib/serviceError.ts +++ b/packages/web/src/lib/serviceError.ts @@ -88,11 +88,11 @@ export const notAuthenticated = (): ServiceError => { } } -export const notFound = (): ServiceError => { +export const notFound = (message?: string): ServiceError => { return { statusCode: StatusCodes.NOT_FOUND, errorCode: ErrorCode.NOT_FOUND, - message: "Not found", + message: message ?? "Not found", } } diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index c1176e87..57946242 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -11,5 +11,18 @@ export enum SearchQueryParams { matches = "matches", } +export type ApiKeyPayload = { + apiKey: string; + domain: string; +}; + +export type NewsItem = { + unique_id: string; + header: string; + sub_header: string; + url: string; + read?: boolean; +} + export type TenancyMode = z.infer; export type RepositoryQuery = z.infer; \ No newline at end of file diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index ddaac1c0..0d01f9ff 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -7,6 +7,9 @@ import gerritLogo from "@/public/gerrit.svg"; import bitbucketLogo from "@/public/bitbucket.svg"; import gitLogo from "@/public/git.svg"; import { ServiceError } from "./serviceError"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "./errorCodes"; +import { NextRequest } from "next/server"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -131,7 +134,7 @@ export const getCodeHostInfoForRepo = (repo: { return { type: "generic-git-host", displayName: displayName ?? name, - codeHostName: "Generic Git Host", + codeHostName: "Git Host", repoLink: webUrl, icon: src, iconClassName: className, @@ -235,7 +238,7 @@ export const getDisplayTime = (date: Date) => { } } -export const measureSync = (cb: () => T, measureName: string) => { +export const measureSync = (cb: () => T, measureName: string, outputLog: boolean = true) => { const startMark = `${measureName}.start`; const endMark = `${measureName}.end`; @@ -245,7 +248,9 @@ export const measureSync = (cb: () => T, measureName: string) => { const measure = performance.measure(measureName, startMark, endMark); const durationMs = measure.duration; - console.debug(`[${measureName}] took ${durationMs}ms`); + if (outputLog) { + console.debug(`[${measureName}] took ${durationMs}ms`); + } return { data, @@ -253,7 +258,7 @@ export const measureSync = (cb: () => T, measureName: string) => { } } -export const measure = async (cb: () => Promise, measureName: string) => { +export const measure = async (cb: () => Promise, measureName: string, outputLog: boolean = true) => { const startMark = `${measureName}.start`; const endMark = `${measureName}.end`; @@ -263,7 +268,9 @@ export const measure = async (cb: () => Promise, measureName: string) => { const measure = performance.measure(measureName, startMark, endMark); const durationMs = measure.duration; - console.debug(`[${measureName}] took ${durationMs}ms`); + if (outputLog) { + console.debug(`[${measureName}] took ${durationMs}ms`); + } return { data, @@ -286,4 +293,16 @@ export const unwrapServiceError = async (promise: Promise): } return data; +} + +export const requiredQueryParamGuard = (request: NextRequest, param: string): ServiceError | string => { + const value = request.nextUrl.searchParams.get(param); + if (!value) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.MISSING_REQUIRED_QUERY_PARAMETER, + message: `Missing required query param: ${param}`, + }; + } + return value; } \ No newline at end of file diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index 25aeba72..e8476db5 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -10,13 +10,7 @@ export async function middleware(request: NextRequest) { return NextResponse.next(); } - // Enable these domains when auth is enabled. - if (env.SOURCEBOT_AUTH_ENABLED === 'true' && - ( - url.pathname.startsWith('/login') || - url.pathname.startsWith('/redeem') - ) - ) { + if (url.pathname.startsWith('/login') || url.pathname.startsWith('/redeem')) { return NextResponse.next(); } diff --git a/packages/web/src/tailwind.ts b/packages/web/src/tailwind.ts new file mode 100644 index 00000000..aafe7770 --- /dev/null +++ b/packages/web/src/tailwind.ts @@ -0,0 +1,5 @@ +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfig from '../tailwind.config'; + +const tailwind = resolveConfig(tailwindConfig); +export default tailwind; \ No newline at end of file diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts new file mode 100644 index 00000000..b5ddca24 --- /dev/null +++ b/packages/web/src/types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const orgMetadataSchema = z.object({ + publicAccessEnabled: z.boolean().optional(), +}) + +export type OrgMetadata = z.infer; \ No newline at end of file diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts index 7b163209..d740177a 100644 --- a/packages/web/tailwind.config.ts +++ b/packages/web/tailwind.config.ts @@ -1,103 +1,153 @@ import type { Config } from "tailwindcss" const config = { - darkMode: ["class"], - content: [ - './pages/**/*.{ts,tsx}', - './components/**/*.{ts,tsx}', - './app/**/*.{ts,tsx}', - './src/**/*.{ts,tsx}', + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'var(--border)', + input: 'var(--input)', + ring: 'var(--ring)', + background: 'var(--background)', + backgroundSecondary: 'var(--background-secondary)', + foreground: 'var(--foreground)', + primary: { + DEFAULT: 'var(--primary)', + foreground: 'var(--primary-foreground)' + }, + secondary: { + DEFAULT: 'var(--secondary)', + foreground: 'var(--secondary-foreground)' + }, + destructive: { + DEFAULT: 'var(--destructive)', + foreground: 'var(--destructive-foreground)' + }, + muted: { + DEFAULT: 'var(--muted)', + foreground: 'var(--muted-foreground)' + }, + accent: { + DEFAULT: 'var(--accent)', + foreground: 'var(--accent-foreground)' + }, + popover: { + DEFAULT: 'var(--popover)', + foreground: 'var(--popover-foreground)' + }, + card: { + DEFAULT: 'var(--card)', + foreground: 'var(--card-foreground)' + }, + highlight: 'var(--highlight)', + sidebar: { + DEFAULT: 'var(--sidebar-background)', + foreground: 'var(--sidebar-foreground)', + primary: 'var(--sidebar-primary)', + 'primary-foreground': 'var(--sidebar-primary-foreground)', + accent: 'var(--sidebar-accent)', + 'accent-foreground': 'var(--sidebar-accent-foreground)', + border: 'var(--sidebar-border)', + ring: 'var(--sidebar-ring)' + }, + editor: { + background: 'var(--editor-background)', + foreground: 'var(--editor-foreground)', + caret: 'var(--editor-caret)', + selection: 'var(--editor-selection)', + selectionMatch: 'var(--editor-selection-match)', + gutterBackground: 'var(--editor-gutter-background)', + gutterForeground: 'var(--editor-gutter-foreground)', + gutterBorder: 'var(--editor-gutter-border)', + gutterActiveForeground: 'var(--editor-gutter-active-foreground)', + lineHighlight: 'var(--editor-line-highlight)', + + tag: { + keyword: 'var(--editor-tag-keyword)', + name: 'var(--editor-tag-name)', + function: 'var(--editor-tag-function)', + label: 'var(--editor-tag-label)', + constant: 'var(--editor-tag-constant)', + definition: 'var(--editor-tag-definition)', + brace: 'var(--editor-tag-brace)', + type: 'var(--editor-tag-type)', + operator: 'var(--editor-tag-operator)', + tag: 'var(--editor-tag-tag)', + 'bracket-square': 'var(--editor-tag-bracket-square)', + 'bracket-angle': 'var(--editor-tag-bracket-angle)', + attribute: 'var(--editor-tag-attribute)', + string: 'var(--editor-tag-string)', + link: 'var(--editor-tag-link)', + meta: 'var(--editor-tag-meta)', + comment: 'var(--editor-tag-comment)', + emphasis: 'var(--editor-tag-emphasis)', + heading: 'var(--editor-tag-heading)', + atom: 'var(--editor-tag-atom)', + processing: 'var(--editor-tag-processing)', + separator: 'var(--editor-tag-separator)', + invalid: 'var(--editor-tag-invalid)', + quote: 'var(--editor-tag-quote)', + 'annotation-special': 'var(--editor-tag-annotation-special)', + number: 'var(--editor-tag-number)', + regexp: 'var(--editor-tag-regexp)', + 'variable-local': 'var(--editor-tag-variable-local)', + } + }, + }, + fontSize: { + editor: 'var(--editor-font-size)' + }, + fontFamily: { + editor: 'var(--editor-font-family)' + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + 'spin-slow': 'spin 1.5s linear infinite' + } + } + }, + plugins: [ + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("tailwindcss-animate"), ], - prefix: "", - theme: { - container: { - center: true, - padding: '2rem', - screens: { - '2xl': '1400px' - } - }, - extend: { - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - backgroundSecondary: 'hsl(var(--background-secondary))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - highlight: 'hsl(var(--highlight))', - sidebar: { - DEFAULT: 'hsl(var(--sidebar-background))', - foreground: 'hsl(var(--sidebar-foreground))', - primary: 'hsl(var(--sidebar-primary))', - 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', - accent: 'hsl(var(--sidebar-accent))', - 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', - border: 'hsl(var(--sidebar-border))', - ring: 'hsl(var(--sidebar-ring))' - } - }, - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)' - }, - keyframes: { - 'accordion-down': { - from: { - height: '0' - }, - to: { - height: 'var(--radix-accordion-content-height)' - } - }, - 'accordion-up': { - from: { - height: 'var(--radix-accordion-content-height)' - }, - to: { - height: '0' - } - } - }, - animation: { - 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out', - 'spin-slow': 'spin 1.5s linear infinite' - } - } - }, - plugins: [ - require("tailwindcss-animate"), - ], } satisfies Config export default config \ No newline at end of file diff --git a/schemas/v3/index.json b/schemas/v3/index.json index a4ba7b89..ed6f182b 100644 --- a/schemas/v3/index.json +++ b/schemas/v3/index.json @@ -61,6 +61,11 @@ "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", + "default": false } }, "additionalProperties": false diff --git a/yarn.lock b/yarn.lock index f3bf9fe6..2ae1187d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -340,7 +340,21 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-cpp@npm:^6.0.2": +"@codemirror/lang-angular@npm:^0.1.0": + version: 0.1.4 + resolution: "@codemirror/lang-angular@npm:0.1.4" + dependencies: + "@codemirror/lang-html": "npm:^6.0.0" + "@codemirror/lang-javascript": "npm:^6.1.2" + "@codemirror/language": "npm:^6.0.0" + "@lezer/common": "npm:^1.2.0" + "@lezer/highlight": "npm:^1.0.0" + "@lezer/lr": "npm:^1.3.3" + checksum: 10c0/9d38350dd5e2defc58b9b18eaf50aa183b9cf8be96270845182f2992b51dbf8af78c567aa736bcd494cbf5aea8400b85b36bfd0131948475f9de7c228b9e3415 + languageName: node + linkType: hard + +"@codemirror/lang-cpp@npm:^6.0.0, @codemirror/lang-cpp@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-cpp@npm:6.0.2" dependencies: @@ -363,7 +377,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-go@npm:^6.0.1": +"@codemirror/lang-go@npm:^6.0.0, @codemirror/lang-go@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-go@npm:6.0.1" dependencies: @@ -393,7 +407,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-java@npm:^6.0.1": +"@codemirror/lang-java@npm:^6.0.0, @codemirror/lang-java@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-java@npm:6.0.1" dependencies: @@ -418,7 +432,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-json@npm:^6.0.1": +"@codemirror/lang-json@npm:^6.0.0, @codemirror/lang-json@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-json@npm:6.0.1" dependencies: @@ -428,7 +442,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-less@npm:^6.0.2": +"@codemirror/lang-less@npm:^6.0.0, @codemirror/lang-less@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-less@npm:6.0.2" dependencies: @@ -441,7 +455,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-liquid@npm:^6.2.2": +"@codemirror/lang-liquid@npm:^6.0.0, @codemirror/lang-liquid@npm:^6.2.2": version: 6.2.3 resolution: "@codemirror/lang-liquid@npm:6.2.3" dependencies: @@ -457,7 +471,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-markdown@npm:^6.2.5": +"@codemirror/lang-markdown@npm:^6.0.0, @codemirror/lang-markdown@npm:^6.2.5": version: 6.3.2 resolution: "@codemirror/lang-markdown@npm:6.3.2" dependencies: @@ -472,7 +486,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-php@npm:^6.0.1": +"@codemirror/lang-php@npm:^6.0.0, @codemirror/lang-php@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-php@npm:6.0.1" dependencies: @@ -485,6 +499,19 @@ __metadata: languageName: node linkType: hard +"@codemirror/lang-python@npm:^6.0.0": + version: 6.2.1 + resolution: "@codemirror/lang-python@npm:6.2.1" + dependencies: + "@codemirror/autocomplete": "npm:^6.3.2" + "@codemirror/language": "npm:^6.8.0" + "@codemirror/state": "npm:^6.0.0" + "@lezer/common": "npm:^1.2.1" + "@lezer/python": "npm:^1.1.4" + checksum: 10c0/6e92ac7e5e6e3162cfbbef40be6314278b9b9ff6f65cfc207a75ec95d84a404b9930913240e1a15e3d18c538f6b243f6a0bba4c3e37fa4e318939dfebc51ebd0 + languageName: node + linkType: hard + "@codemirror/lang-python@npm:^6.1.6": version: 6.1.7 resolution: "@codemirror/lang-python@npm:6.1.7" @@ -498,7 +525,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-rust@npm:^6.0.1": +"@codemirror/lang-rust@npm:^6.0.0, @codemirror/lang-rust@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-rust@npm:6.0.1" dependencies: @@ -508,7 +535,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-sass@npm:^6.0.2": +"@codemirror/lang-sass@npm:^6.0.0, @codemirror/lang-sass@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-sass@npm:6.0.2" dependencies: @@ -521,7 +548,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-sql@npm:^6.7.1": +"@codemirror/lang-sql@npm:^6.0.0, @codemirror/lang-sql@npm:^6.7.1": version: 6.8.0 resolution: "@codemirror/lang-sql@npm:6.8.0" dependencies: @@ -535,7 +562,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-vue@npm:^0.1.3": +"@codemirror/lang-vue@npm:^0.1.1, @codemirror/lang-vue@npm:^0.1.3": version: 0.1.3 resolution: "@codemirror/lang-vue@npm:0.1.3" dependencies: @@ -549,7 +576,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-wast@npm:^6.0.2": +"@codemirror/lang-wast@npm:^6.0.0, @codemirror/lang-wast@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-wast@npm:6.0.2" dependencies: @@ -561,7 +588,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-xml@npm:^6.1.0": +"@codemirror/lang-xml@npm:^6.0.0, @codemirror/lang-xml@npm:^6.1.0": version: 6.1.0 resolution: "@codemirror/lang-xml@npm:6.1.0" dependencies: @@ -575,7 +602,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-yaml@npm:^6.1.1, @codemirror/lang-yaml@npm:^6.1.2": +"@codemirror/lang-yaml@npm:^6.0.0, @codemirror/lang-yaml@npm:^6.1.1, @codemirror/lang-yaml@npm:^6.1.2": version: 6.1.2 resolution: "@codemirror/lang-yaml@npm:6.1.2" dependencies: @@ -590,6 +617,36 @@ __metadata: languageName: node linkType: hard +"@codemirror/language-data@npm:^6.5.1": + version: 6.5.1 + resolution: "@codemirror/language-data@npm:6.5.1" + dependencies: + "@codemirror/lang-angular": "npm:^0.1.0" + "@codemirror/lang-cpp": "npm:^6.0.0" + "@codemirror/lang-css": "npm:^6.0.0" + "@codemirror/lang-go": "npm:^6.0.0" + "@codemirror/lang-html": "npm:^6.0.0" + "@codemirror/lang-java": "npm:^6.0.0" + "@codemirror/lang-javascript": "npm:^6.0.0" + "@codemirror/lang-json": "npm:^6.0.0" + "@codemirror/lang-less": "npm:^6.0.0" + "@codemirror/lang-liquid": "npm:^6.0.0" + "@codemirror/lang-markdown": "npm:^6.0.0" + "@codemirror/lang-php": "npm:^6.0.0" + "@codemirror/lang-python": "npm:^6.0.0" + "@codemirror/lang-rust": "npm:^6.0.0" + "@codemirror/lang-sass": "npm:^6.0.0" + "@codemirror/lang-sql": "npm:^6.0.0" + "@codemirror/lang-vue": "npm:^0.1.1" + "@codemirror/lang-wast": "npm:^6.0.0" + "@codemirror/lang-xml": "npm:^6.0.0" + "@codemirror/lang-yaml": "npm:^6.0.0" + "@codemirror/language": "npm:^6.0.0" + "@codemirror/legacy-modes": "npm:^6.4.0" + checksum: 10c0/5a5dfeaa5c6fba019c7ff3a380ffb11956607f9bc5556537cb0515a367fb6294628fb36b449641d82f56b2236bccae88d0741469183c71cb7bf80ea7861e8fba + languageName: node + linkType: hard + "@codemirror/language@npm:6.x, @codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.10.2, @codemirror/language@npm:^6.10.3, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0, @codemirror/language@npm:^6.8.0, @codemirror/language@npm:^6.9.0": version: 6.11.0 resolution: "@codemirror/language@npm:6.11.0" @@ -604,6 +661,15 @@ __metadata: languageName: node linkType: hard +"@codemirror/legacy-modes@npm:^6.4.0": + version: 6.5.1 + resolution: "@codemirror/legacy-modes@npm:6.5.1" + dependencies: + "@codemirror/language": "npm:^6.0.0" + checksum: 10c0/a5fc0c76112f1fe4add414c65876932c24d77126ee4504049fd188abc4e44c5da611beaa46cfe45d5269d6d7b49aefc10c410d457785a39ba3c233f799802cf0 + languageName: node + linkType: hard + "@codemirror/legacy-modes@npm:^6.4.2": version: 6.5.0 resolution: "@codemirror/legacy-modes@npm:6.5.0" @@ -1278,6 +1344,17 @@ __metadata: languageName: node linkType: hard +"@eslint-community/eslint-utils@npm:^4.7.0": + version: 4.7.0 + resolution: "@eslint-community/eslint-utils@npm:4.7.0" + dependencies: + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/c0f4f2bd73b7b7a9de74b716a664873d08ab71ab439e51befe77d61915af41a81ecec93b408778b3a7856185244c34c2c8ee28912072ec14def84ba2dec70adf + languageName: node + linkType: hard + "@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.6.1": version: 4.12.1 resolution: "@eslint-community/regexpp@npm:4.12.1" @@ -1821,7 +1898,7 @@ __metadata: languageName: node linkType: hard -"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.7, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2, @lezer/lr@npm:^1.x": +"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.3.7, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2, @lezer/lr@npm:^1.x": version: 1.4.2 resolution: "@lezer/lr@npm:1.4.2" dependencies: @@ -3163,6 +3240,13 @@ __metadata: languageName: node linkType: hard +"@radix-ui/primitive@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/primitive@npm:1.1.2" + checksum: 10c0/5e2d2528d2fe37c16865e77b0beaac2b415a817ad13d8178db6e8187b2a092672568a64ee0041510abfde3034490a5cadd3057049bb15789020c06892047597c + languageName: node + linkType: hard + "@radix-ui/react-alert-dialog@npm:^1.1.5": version: 1.1.6 resolution: "@radix-ui/react-alert-dialog@npm:1.1.6" @@ -3228,6 +3312,32 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-checkbox@npm:^1.3.2": + version: 1.3.2 + resolution: "@radix-ui/react-checkbox@npm:1.3.2" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-presence": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-previous": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/8be7c06b3a7d3cff099cca1ccaf65258d65d9f10b5bb3a78ff6fc024799ac78befb3dfbb2965900c409a3dcbb99458bd3a9925392299f9f150a4f35eef040c59 + languageName: node + linkType: hard + "@radix-ui/react-collection@npm:1.1.2": version: 1.1.2 resolution: "@radix-ui/react-collection@npm:1.1.2" @@ -3278,6 +3388,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-compose-refs@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-compose-refs@npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/d36a9c589eb75d634b9b139c80f916aadaf8a68a7c1c4b8c6c6b88755af1a92f2e343457042089f04cc3f23073619d08bb65419ced1402e9d4e299576d970771 + languageName: node + linkType: hard + "@radix-ui/react-context@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-context@npm:1.0.1" @@ -3306,6 +3429,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-context@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-context@npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/cece731f8cc25d494c6589cc681e5c01a93867d895c75889973afa1a255f163c286e390baa7bc028858eaabe9f6b57270d0ca6377356f652c5557c1c7a41ccce + languageName: node + linkType: hard + "@radix-ui/react-dialog@npm:1.0.5": version: 1.0.5 resolution: "@radix-ui/react-dialog@npm:1.0.5" @@ -3823,6 +3959,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-presence@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-presence@npm:1.1.4" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/8202647139d6f5097b0abcc43dfba471c00b69da95ca336afe3ea23a165e05ca21992f40fc801760fe442f3e064e54e2f2cbcb9ad758c4b07ef6c69a5b6777bd + languageName: node + linkType: hard + "@radix-ui/react-primitive@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-primitive@npm:1.0.3" @@ -3862,6 +4018,44 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-primitive@npm:2.1.2": + version: 2.1.2 + resolution: "@radix-ui/react-primitive@npm:2.1.2" + dependencies: + "@radix-ui/react-slot": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/0c1b4b5d2f225dc85e02a915b362e38383eb3bd6d150a72cb9183485c066156caad1a9f530202b84a5ad900d365302c0843fdcabb13100808872b3655709099d + languageName: node + linkType: hard + +"@radix-ui/react-primitive@npm:2.1.3": + version: 2.1.3 + resolution: "@radix-ui/react-primitive@npm:2.1.3" + dependencies: + "@radix-ui/react-slot": "npm:1.2.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/fdff9b84913bb4172ef6d3af7442fca5f9bba5f2709cba08950071f819d7057aec3a4a2d9ef44cf9cbfb8014d02573c6884a04cff175895823aaef809ebdb034 + languageName: node + linkType: hard + "@radix-ui/react-roving-focus@npm:1.1.2": version: 1.1.2 resolution: "@radix-ui/react-roving-focus@npm:1.1.2" @@ -4005,6 +4199,61 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-slot@npm:1.2.2": + version: 1.2.2 + resolution: "@radix-ui/react-slot@npm:1.2.2" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/74489f5ad11b17444560a1cdd664c01308206ce5cb9fcd46121b45281ece20273948479711411223c1081f709c15409242d51ca6cc57ff81f6335d70e0cbe0b5 + languageName: node + linkType: hard + +"@radix-ui/react-slot@npm:1.2.3": + version: 1.2.3 + resolution: "@radix-ui/react-slot@npm:1.2.3" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/5913aa0d760f505905779515e4b1f0f71a422350f077cc8d26d1aafe53c97f177fec0e6d7fbbb50d8b5e498aa9df9f707ca75ae3801540c283b26b0136138eef + languageName: node + linkType: hard + +"@radix-ui/react-switch@npm:^1.2.4": + version: 1.2.4 + resolution: "@radix-ui/react-switch@npm:1.2.4" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-previous": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/53f1f985dd0ed7b28b108b8075078912fed1496313f23270f0be3234fedebb5df4b4b33f2cb1a9c5670d48a38200fc4e1f09db2608c3e94d1de61339ac2d2c53 + languageName: node + linkType: hard + "@radix-ui/react-tabs@npm:^1.1.2": version: 1.1.3 resolution: "@radix-ui/react-tabs@npm:1.1.3" @@ -4171,6 +4420,37 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-controllable-state@npm:1.2.2": + version: 1.2.2 + resolution: "@radix-ui/react-use-controllable-state@npm:1.2.2" + dependencies: + "@radix-ui/react-use-effect-event": "npm:0.0.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/f55c4b06e895293aed4b44c9ef26fb24432539f5346fcd6519c7745800535b571058685314e83486a45bf61dc83887e24826490d3068acc317fb0a9010516e63 + languageName: node + linkType: hard + +"@radix-ui/react-use-effect-event@npm:0.0.2": + version: 0.0.2 + resolution: "@radix-ui/react-use-effect-event@npm:0.0.2" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/e84ff72a3e76c5ae9c94941028bb4b6472f17d4104481b9eab773deab3da640ecea035e54da9d6f4df8d84c18ef6913baf92b7511bee06930dc58bd0c0add417 + languageName: node + linkType: hard + "@radix-ui/react-use-escape-keydown@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-use-escape-keydown@npm:1.0.3" @@ -4230,6 +4510,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-layout-effect@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/9f98fdaba008dfc58050de60a77670b885792df473cf82c1cef8daee919a5dd5a77d270209f5f0b0abfaac78cb1627396e3ff56c81b735be550409426fe8b040 + languageName: node + linkType: hard + "@radix-ui/react-use-previous@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-use-previous@npm:1.1.0" @@ -4243,6 +4536,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-previous@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-use-previous@npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/52f1089d941491cd59b7f52a5679a14e9381711419a0557ce0f3bc9a4c117078224efec54dcced41a3653a13a386a7b6ec75435d61a273e8b9f5d00235f2b182 + languageName: node + linkType: hard + "@radix-ui/react-use-rect@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-use-rect@npm:1.1.0" @@ -4273,6 +4579,21 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-size@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-use-size@npm:1.1.1" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/851d09a816f44282e0e9e2147b1b571410174cc048703a50c4fa54d672de994fd1dfff1da9d480ecfd12c77ae8f48d74f01adaf668f074156b8cd0043c6c21d8 + languageName: node + linkType: hard + "@radix-ui/react-visually-hidden@npm:1.1.2": version: 1.1.2 resolution: "@radix-ui/react-visually-hidden@npm:1.1.2" @@ -5572,6 +5893,7 @@ __metadata: "@codemirror/lang-xml": "npm:^6.1.0" "@codemirror/lang-yaml": "npm:^6.1.2" "@codemirror/language": "npm:^6.0.0" + "@codemirror/language-data": "npm:^6.5.1" "@codemirror/legacy-modes": "npm:^6.4.2" "@codemirror/search": "npm:^6.5.6" "@codemirror/state": "npm:^6.4.1" @@ -5582,6 +5904,7 @@ __metadata: "@iizukak/codemirror-lang-wgsl": "npm:^0.3.0" "@radix-ui/react-alert-dialog": "npm:^1.1.5" "@radix-ui/react-avatar": "npm:^1.1.2" + "@radix-ui/react-checkbox": "npm:^1.3.2" "@radix-ui/react-dialog": "npm:^1.1.4" "@radix-ui/react-dropdown-menu": "npm:^2.1.1" "@radix-ui/react-hover-card": "npm:^1.1.6" @@ -5593,6 +5916,7 @@ __metadata: "@radix-ui/react-select": "npm:^2.1.6" "@radix-ui/react-separator": "npm:^1.1.0" "@radix-ui/react-slot": "npm:^1.1.1" + "@radix-ui/react-switch": "npm:^1.2.4" "@radix-ui/react-tabs": "npm:^1.1.2" "@radix-ui/react-toast": "npm:^1.2.2" "@radix-ui/react-toggle": "npm:^1.1.0" @@ -5614,6 +5938,7 @@ __metadata: "@stripe/react-stripe-js": "npm:^3.1.1" "@stripe/stripe-js": "npm:^5.6.0" "@t3-oss/env-nextjs": "npm:^0.12.0" + "@tanstack/eslint-plugin-query": "npm:^5.74.7" "@tanstack/react-query": "npm:^5.53.3" "@tanstack/react-table": "npm:^8.20.5" "@tanstack/react-virtual": "npm:^3.10.8" @@ -5625,6 +5950,7 @@ __metadata: "@types/react-dom": "npm:^18" "@typescript-eslint/eslint-plugin": "npm:^8.3.0" "@typescript-eslint/parser": "npm:^8.3.0" + "@uidotdev/usehooks": "npm:^2.4.1" "@uiw/codemirror-themes": "npm:^4.23.6" "@uiw/react-codemirror": "npm:^4.23.0" "@viz-js/lang-dot": "npm:^1.0.4" @@ -5808,6 +6134,17 @@ __metadata: languageName: node linkType: hard +"@tanstack/eslint-plugin-query@npm:^5.74.7": + version: 5.74.7 + resolution: "@tanstack/eslint-plugin-query@npm:5.74.7" + dependencies: + "@typescript-eslint/utils": "npm:^8.18.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + checksum: 10c0/7f026af15918e0f77e1032c0e53d70fd952d32735b0987a84d0df2b1c6b47ac01773da3812d579c999c398dd677d45400e133a1b3c2979e3f125028743451850 + languageName: node + linkType: hard + "@tanstack/query-core@npm:5.69.0": version: 5.69.0 resolution: "@tanstack/query-core@npm:5.69.0" @@ -6310,6 +6647,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/scope-manager@npm:8.32.1" + dependencies: + "@typescript-eslint/types": "npm:8.32.1" + "@typescript-eslint/visitor-keys": "npm:8.32.1" + checksum: 10c0/d2cb1f7736388972137d6e510b2beae4bac033fcab274e04de90ebba3ce466c71fe47f1795357e032e4a6c8b2162016b51b58210916c37212242c82d35352e9f + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.27.0": version: 8.27.0 resolution: "@typescript-eslint/type-utils@npm:8.27.0" @@ -6339,6 +6686,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/types@npm:8.32.1" + checksum: 10c0/86f59b29c12e7e8abe45a1659b6fae5e7b0cfaf09ab86dd596ed9d468aa61082bbccd509d25f769b197fbfdf872bbef0b323a2ded6ceaca351f7c679f1ba3bd3 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:7.2.0": version: 7.2.0 resolution: "@typescript-eslint/typescript-estree@npm:7.2.0" @@ -6376,6 +6730,24 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.32.1" + dependencies: + "@typescript-eslint/types": "npm:8.32.1" + "@typescript-eslint/visitor-keys": "npm:8.32.1" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10c0/b5ae0d91ef1b46c9f3852741e26b7a14c28bb58ee8a283b9530ac484332ca58a7216b9d22eda23c5449b5fd69c6e4601ef3ebbd68e746816ae78269036c08cda + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:8.27.0": version: 8.27.0 resolution: "@typescript-eslint/utils@npm:8.27.0" @@ -6391,6 +6763,21 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:^8.18.1": + version: 8.32.1 + resolution: "@typescript-eslint/utils@npm:8.32.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.32.1" + "@typescript-eslint/types": "npm:8.32.1" + "@typescript-eslint/typescript-estree": "npm:8.32.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10c0/a2b90c0417cd3a33c6e22f9cc28c356f251bb8928ef1d25e057feda007d522d281bdc37a9a0d05b70312f00a7b3f350ca06e724867025ea85bba5a4c766732e7 + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:7.2.0": version: 7.2.0 resolution: "@typescript-eslint/visitor-keys@npm:7.2.0" @@ -6411,6 +6798,26 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.32.1" + dependencies: + "@typescript-eslint/types": "npm:8.32.1" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10c0/9c05053dfd048f681eb96e09ceefa8841a617b8b5950eea05e0844b38fe3510a284eb936324caa899c3ceb4bc23efe56ac01437fab378ac1beeb1c6c00404978 + languageName: node + linkType: hard + +"@uidotdev/usehooks@npm:^2.4.1": + version: 2.4.1 + resolution: "@uidotdev/usehooks@npm:2.4.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/181c43fb324dbe4fef9762c61ab4b8235efa48abedf39a9bfeab65872522c43dae789c4f85b82a1164ed7bb18ae7ff25c3a19e7c4e0eb944937ac7f8109cee9b + languageName: node + linkType: hard + "@uiw/codemirror-extensions-basic-setup@npm:4.23.10": version: 4.23.10 resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.10" @@ -15134,7 +15541,7 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.0.1": +"ts-api-utils@npm:^2.0.1, ts-api-utils@npm:^2.1.0": version: 2.1.0 resolution: "ts-api-utils@npm:2.1.0" peerDependencies: