diff --git a/.env.development b/.env.development index e9029f539..89b9b9e14 100644 --- a/.env.development +++ b/.env.development @@ -13,7 +13,6 @@ CTAGS_COMMAND=ctags # @see: https://authjs.dev/getting-started/deployment#auth_secret AUTH_SECRET="00000000000000000000000000000000000000000000" AUTH_URL="http://localhost:3000" -# AUTH_CREDENTIALS_LOGIN_ENABLED=true DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot) SOURCEBOT_PUBLIC_KEY_PATH=${PWD}/public.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index ab841e434..73582aabb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Changed the workspace "Access" page to a "Security" page. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303) + +### Added +- Added the ability to configure email code and credentials login from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303) +- Added a list of configured SSO providers from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303) + ## [5.0.2] - 2026-06-11 ### Changed diff --git a/docs/docs/configuration/auth/access-settings.mdx b/docs/docs/configuration/auth/access-settings.mdx index add0c3827..96ad0c5a7 100644 --- a/docs/docs/configuration/auth/access-settings.mdx +++ b/docs/docs/configuration/auth/access-settings.mdx @@ -11,9 +11,11 @@ By default, Sourcebot requires new members to be approved by an owner of the dep to configure this behavior. ### Configuration -Member approval can be configured by an owner of the deployment by navigating to **Settings -> Access**, or by setting the `REQUIRE_APPROVAL_NEW_MEMBERS` environment variable. When the environment variable is set, the UI toggle is disabled and the setting is controlled by the environment variable. +Member approval can be configured by an owner of the deployment by navigating to **Settings -> Security**. -![Member Approval Toggle](/images/member_approval_toggle.png) + + Require approval for new members toggle in Settings → Access + ### Managing Requests @@ -27,7 +29,9 @@ Owners can see and manage all pending join requests by navigating to **Settings If member approval is required, an owner of the deployment can enable an invite link. When enabled, users can use this invite link to register and be automatically added to the organization without approval: -![Invite Link Toggle](/images/invite_link_toggle.png) + + Enable invite links toggle in Settings → Access + # Anonymous access @@ -36,6 +40,10 @@ can use this invite link to register and be automatically added to the organizat By default, your Sourcebot deployment is gated with a login page. If you'd like users to access the deployment anonymously, you can enable anonymous access. -This can be enabled by navigating to **Settings -> Access** or by setting the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable. +This can be enabled by navigating to **Settings -> Access**. + + + Enable anonymous access toggle in Settings → Access + When accessing Sourcebot anonymously, a user's permissions are limited to that of the [Guest](/docs/configuration/auth/roles-and-permissions) role. diff --git a/docs/docs/configuration/auth/providers.mdx b/docs/docs/configuration/auth/providers.mdx index 61e682a09..b28f8c32c 100644 --- a/docs/docs/configuration/auth/providers.mdx +++ b/docs/docs/configuration/auth/providers.mdx @@ -10,19 +10,18 @@ If there's an authentication provider you'd like us to support, please [reach ou # 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 / password authentication is enabled by default. You can toggle it from **Settings → Security** using the **Email login** setting. -### 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` + + Email & password login setting toggle in Settings → Security + +### Email codes +Email codes are 6 digit codes sent to a provided email. Email codes are enabled when [transactional emails](/docs/configuration/transactional-emails) and the **Email code** setting is toggled from **Settings → Security**: -See [transactional emails](/docs/configuration/transactional-emails) for more details. + + Email code login setting toggle in Settings → Security + # Enterprise Authentication Providers diff --git a/docs/docs/configuration/config-file.mdx b/docs/docs/configuration/config-file.mdx index d6162451d..ccf3e2301 100644 --- a/docs/docs/configuration/config-file.mdx +++ b/docs/docs/configuration/config-file.mdx @@ -49,7 +49,6 @@ The following are settings that can be provided in your config file to modify So | `maxRepoGarbageCollectionJobConcurrency` | number | 8 | 1 | Concurrent repo‑garbage‑collection jobs. | | `repoGarbageCollectionGracePeriodMs` | number | 10 seconds | 1 | Grace period to avoid deleting shards while loading. | | `repoIndexTimeoutMs` | number | 2 hours | 1 | Timeout for a single repo‑indexing run. | -| `enablePublicAccess` **(deprecated)** | boolean | false | — | Use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. | | `repoDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 | Interval at which the repo permission syncer should run. | | `userDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 | Interval at which the user permission syncer should run. | | `experiment_repoDrivenPermissionSyncIntervalMs` **(deprecated)** | number | 24 hours | 1 | Use `repoDrivenPermissionSyncIntervalMs` instead. | diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 8c0415fc4..2d4c57acb 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -10,8 +10,6 @@ The following environment variables allow you to configure your Sourcebot deploy | 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](/docs/configuration/auth/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](/docs/configuration/auth/authentication) for more info

| | `AUTH_SECRET` **(required)** | - |

Used to validate login session cookies. Genearte one with `openssl rand -base64 33`.

| | `AUTH_SESSION_MAX_AGE_SECONDS` | `2592000` (30 days) |

Relative time from now in seconds when to expire the session.

| | `AUTH_SESSION_UPDATE_AGE_SECONDS` | `86400` (1 day) |

How often the session should be updated in seconds. If set to `0`, session is updated every time.

| @@ -24,8 +22,6 @@ The following environment variables allow you to configure your Sourcebot deploy | `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_URL` **(required)** | - |

Connection string of your Postgres database, e.g. `postgresql://user:password@host:5432/sourcebot`.

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

You can also use `DATABASE_HOST`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_NAME`, and `DATABASE_ARGS` to construct the database url.

| | `EMAIL_FROM_ADDRESS` | `-` |

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

| -| `FORCE_ENABLE_ANONYMOUS_ACCESS` | `false` |

When enabled, [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) to the organization will always be enabled

-| `REQUIRE_APPROVAL_NEW_MEMBERS` | - |

When set, controls whether new users require approval before accessing your deployment. If not set, the setting can be configured via the UI. See [member approval](/docs/configuration/auth/access-settings#member-approval) for more info.

| `REDIS_URL` **(required)** | - |

Connection string of your Redis instance, e.g. `redis://host:6379`.

To enable TLS, see [this doc](/docs/deployment/infrastructure/redis#tls).

| | `REDIS_REMOVE_ON_COMPLETE` | `0` |

Controls how many completed jobs are allowed to remain in Redis queues

| | `REDIS_REMOVE_ON_FAIL` | `100` |

Controls how many failed jobs are allowed to remain in Redis queues

| @@ -54,10 +50,8 @@ The following environment variables allow you to configure your Sourcebot deploy | `AUTH_EE_GCP_IAP_AUDIENCE` | - |

The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning

| | `PERMISSION_SYNC_ENABLED` | `false` |

Enables [permission syncing](/docs/features/permission-syncing).

| | `PERMISSION_SYNC_REPO_DRIVEN_ENABLED` | `true` |

Enables/disables [repo-driven permission syncing](/docs/features/permission-syncing#how-it-works). Only applies when `PERMISSION_SYNC_ENABLED` is `true`.

| -| `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` **(deprecated)** | `false` |

Deprecated. Use `PERMISSION_SYNC_ENABLED` instead.

| | `AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING` | `true` |

When enabled, different SSO accounts with the same email address will automatically be linked.

| | `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` | `false` |

When enabled, only organization owners can create API keys. Non-owner members will receive a `403` error if they attempt to create one.

| -| `EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS` **(deprecated)** | `false` |

Deprecated. Use `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` instead.

| | `DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS` | `false` |

When enabled, only organization owners can create or use API keys. Non-owner members will receive a `403` error if they attempt to create or authenticate with an API key. If you only want to restrict creation (not usage), use `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` instead.

| diff --git a/docs/images/anonymous_access_toggle.png b/docs/images/anonymous_access_toggle.png new file mode 100644 index 000000000..bc86be974 Binary files /dev/null and b/docs/images/anonymous_access_toggle.png differ diff --git a/docs/images/demote_to_member.png b/docs/images/demote_to_member.png index 5b8282f95..775e79546 100644 Binary files a/docs/images/demote_to_member.png and b/docs/images/demote_to_member.png differ diff --git a/docs/images/email_code_login_setting.png b/docs/images/email_code_login_setting.png new file mode 100644 index 000000000..870e6f40e Binary files /dev/null and b/docs/images/email_code_login_setting.png differ diff --git a/docs/images/email_password_login_setting.png b/docs/images/email_password_login_setting.png new file mode 100644 index 000000000..def59a00e Binary files /dev/null and b/docs/images/email_password_login_setting.png differ diff --git a/docs/images/invite_link_toggle.png b/docs/images/invite_link_toggle.png index 979033e82..78391408a 100644 Binary files a/docs/images/invite_link_toggle.png and b/docs/images/invite_link_toggle.png differ diff --git a/docs/images/managing_owners.png b/docs/images/managing_owners.png index 7a4d0e04c..c3afa0124 100644 Binary files a/docs/images/managing_owners.png and b/docs/images/managing_owners.png differ diff --git a/docs/images/member_approval_toggle.png b/docs/images/member_approval_toggle.png index e6c2cfac0..1de0666a5 100644 Binary files a/docs/images/member_approval_toggle.png and b/docs/images/member_approval_toggle.png differ diff --git a/docs/images/owner_leave_org.png b/docs/images/owner_leave_org.png index da293c2d4..581a0eb2a 100644 Binary files a/docs/images/owner_leave_org.png and b/docs/images/owner_leave_org.png differ diff --git a/docs/images/promote_to_owner.png b/docs/images/promote_to_owner.png index 7f197e113..a1c9b85de 100644 Binary files a/docs/images/promote_to_owner.png and b/docs/images/promote_to_owner.png differ diff --git a/packages/db/prisma/migrations/20260611195453_add_org_access_settings/migration.sql b/packages/db/prisma/migrations/20260611195453_add_org_access_settings/migration.sql new file mode 100644 index 000000000..f3657aea3 --- /dev/null +++ b/packages/db/prisma/migrations/20260611195453_add_org_access_settings/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "isAnonymousAccessEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isCredentialsLoginEnabled" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "isEmailCodeLoginEnabled" BOOLEAN NOT NULL DEFAULT false; + +-- Backfill the new dedicated column from the legacy `metadata.anonymousAccessEnabled` +-- value (see orgMetadataSchema in packages/web/src/types.ts) so existing deployments +-- that had anonymous access enabled keep it after upgrading. +UPDATE "Org" +SET "isAnonymousAccessEnabled" = true +WHERE "metadata"->>'anonymousAccessEnabled' = 'true'; diff --git a/packages/db/prisma/migrations/20260612182049_remove_org_metadata_field/migration.sql b/packages/db/prisma/migrations/20260612182049_remove_org_metadata_field/migration.sql new file mode 100644 index 000000000..0dece6bdc --- /dev/null +++ b/packages/db/prisma/migrations/20260612182049_remove_org_metadata_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `metadata` on the `Org` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Org" DROP COLUMN "metadata"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 815788d64..ae0a53840 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -274,10 +274,31 @@ model Org { apiKeys ApiKey[] isOnboarded Boolean @default(false) imageUrl String? - metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts + /// @deprecated This property can be controlled by the environment + /// variable `REQUIRE_APPROVAL_NEW_MEMBERS`. To ensure that we use + /// the correct setting, use the helper function `isMemberApprovalRequired` + /// in shared/src/utils.ts memberApprovalRequired Boolean @default(true) + /// @deprecated This property can be controlled by the environment + /// variable `AUTH_CREDENTIALS_LOGIN_ENABLED`. To ensure that we use + /// the correct setting, use the helper function `isCredentialsLoginEnabled` + /// in shared/src/utils.ts + isCredentialsLoginEnabled Boolean @default(true) + + /// @deprecated This property can be controlled by the environment + /// variable `AUTH_EMAIL_CODE_LOGIN_ENABLED`. To ensure that we use + /// the correct setting, use the helper function `isEmailCodeLoginEnabled` + /// in shared/src/utils.ts + isEmailCodeLoginEnabled Boolean @default(false) + + /// @deprecated This property can be overriden by the environment + /// variable `FORCE_ENABLE_ANONYMOUS_ACCESS`, as well as the org's + /// available entitlements. Use the helper function `isAnonymousAccessEnabled` + /// in web/src/lib/entitlements.ts + isAnonymousAccessEnabled Boolean @default(false) + /// List of pending invites to this organization invites Invite[] diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 180b38b68..5bb33d146 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,7 +1,7 @@ import { CodeHostType } from "@sourcebot/db"; import { ConfigSettings, IdentityProviderType } from "./types.js"; -export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev'; +export const SOURCEBOT_SUPPORT_EMAIL = 'support@sourcebot.dev'; /** * @deprecated Use API_KEY_PREFIX instead. diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 3401f7a24..b6d49327c 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -173,12 +173,8 @@ const options = { ZOEKT_WEBSERVER_URL: z.string().url().default("http://localhost:6070"), // Auth - FORCE_ENABLE_ANONYMOUS_ACCESS: booleanSchema.default('false'), - REQUIRE_APPROVAL_NEW_MEMBERS: booleanSchema.optional(), AUTH_SECRET: z.string(), AUTH_URL: z.string().url(), - AUTH_CREDENTIALS_LOGIN_ENABLED: booleanSchema.default('true'), - AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.default('false'), /** * Relative time from now in seconds when to expire the session. @@ -308,20 +304,11 @@ const options = { GOOGLE_VERTEX_REGION: z.string().default('us-central1'), GOOGLE_APPLICATION_CREDENTIALS: z.string().optional(), - /** - * @deprecated Use `thinkingBudget` in the language model config instead. - */ - GOOGLE_VERTEX_THINKING_BUDGET_TOKENS: numberSchema.optional(), - AWS_ACCESS_KEY_ID: z.string().optional(), AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_SESSION_TOKEN: z.string().optional(), AWS_REGION: z.string().optional(), - /** - * @deprecated Use per-model `temperature` in the language model config instead. - */ - SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.optional(), SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100), SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'), SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000), @@ -342,12 +329,6 @@ const options = { return value ?? ((process.env.EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS as 'true' | 'false') ?? 'false'); }), - /** - * @deprecated Use `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` instead. - */ - EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS: booleanSchema.default('false'), - - // Experimental Environment Variables // @note: These environment variables are subject to change at any time and are not garunteed to be backwards compatible. EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED: booleanSchema.default('false'), @@ -407,11 +388,6 @@ const options = { return value ?? ((process.env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED as 'true' | 'false') ?? 'false'); }), - /** - * @deprecated Use `PERMISSION_SYNC_ENABLED` instead. - */ - EXPERIMENT_EE_PERMISSION_SYNC_ENABLED: booleanSchema.default('false'), - /** * Configure whether to send telemetry events. * By default, all events are anonymized and do not contain PII data, @@ -425,6 +401,59 @@ const options = { * ignored. */ SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: booleanSchema.default('false'), + + //////////// Deprecated //////////// + /** + * @deprecated Configure this setting via the "Require approval + * for new members" toggle in Settings → Security intsead. + */ + REQUIRE_APPROVAL_NEW_MEMBERS: booleanSchema.optional(), + + /** + * @deprecated Configure email + password login via the "Email & password login" + * toggle in Settings → Security instead. When set, this env var overrides the UI + * setting and locks the toggle; when unset, the DB-backed + * `Org.isCredentialsLoginEnabled` setting is used. + */ + AUTH_CREDENTIALS_LOGIN_ENABLED: booleanSchema.optional(), + + /** + * @deprecated Configure email code login via the UI in Settings → Security + * instead. When set, this env var overrides the UI setting and locks the toggle; + * when unset, the DB-backed `Org.isEmailCodeLoginEnabled` setting is used. Left + * optional (rather than defaulting to 'false') so we can detect whether it was + * explicitly set. + */ + AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.optional(), + + /** + * @deprecated Configure anonymous access via the UI in Settings → Security + * instead. When set, this env var overrides the UI setting and locks the toggle; + * when unset, the DB-backed `Org.isAnonymousAccessEnabled` setting is used. Left + * optional (rather than defaulting to 'false') so we can detect whether it was + * explicitly set. + */ + FORCE_ENABLE_ANONYMOUS_ACCESS: booleanSchema.optional(), + + /** + * @deprecated Use `PERMISSION_SYNC_ENABLED` instead. + */ + EXPERIMENT_EE_PERMISSION_SYNC_ENABLED: booleanSchema.default('false'), + + /** + * @deprecated Use `thinkingBudget` in the language model config instead. + */ + GOOGLE_VERTEX_THINKING_BUDGET_TOKENS: numberSchema.optional(), + + /** + * @deprecated Use per-model `temperature` in the language model config instead. + */ + SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.optional(), + + /** + * @deprecated Use `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` instead. + */ + EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS: booleanSchema.default('false'), }, runtimeEnv, emptyStringAsUndefined: true, diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index ee5fe9979..0c8f281a4 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -31,6 +31,9 @@ export { getConfigSettings, getRepoPath, getRepoIdFromPath, + isCredentialsLoginEnabled, + isEmailCodeLoginEnabled, + isMemberApprovalRequired, } from "./utils.js"; export * from "./constants.js"; export { diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 562674e84..848b941eb 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -3,9 +3,10 @@ import stripJsonComments from 'strip-json-comments'; import { z } from "zod"; import { DEFAULT_CONFIG_SETTINGS } from "./constants.js"; import { ConfigSettings } from "./types.js"; -import { Repo } from "@sourcebot/db"; +import { Org, Repo } from "@sourcebot/db"; import path from "path"; import { env, isRemotePath, loadConfig } from "./env.server.js"; +import { isAnonymousAccessAvailable } from './entitlements.js'; // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem export const base64Decode = (base64: string): string => { @@ -118,4 +119,28 @@ export const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } = path: path.join(reposPath, repo.id.toString()), isReadOnly: false, } +} + +export const isCredentialsLoginEnabled = (org: Org): boolean => { + if (env.AUTH_CREDENTIALS_LOGIN_ENABLED !== undefined) { + return env.AUTH_CREDENTIALS_LOGIN_ENABLED === 'true'; + } + + return org.isCredentialsLoginEnabled; +} + +export const isEmailCodeLoginEnabled = (org: Org): boolean => { + if (env.AUTH_EMAIL_CODE_LOGIN_ENABLED !== undefined) { + return env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true'; + } + + return org.isEmailCodeLoginEnabled; +} + +export const isMemberApprovalRequired = (org: Org): boolean => { + if (env.REQUIRE_APPROVAL_NEW_MEMBERS !== undefined) { + return env.REQUIRE_APPROVAL_NEW_MEMBERS === 'true'; + } + + return org.memberApprovalRequired; } \ No newline at end of file diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 6093209b7..5e5c28682 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -19,6 +19,9 @@ export const MOCK_ORG: Org = { imageUrl: null, metadata: null, memberApprovalRequired: false, + isCredentialsLoginEnabled: true, + isEmailCodeLoginEnabled: false, + isAnonymousAccessEnabled: false, inviteLinkEnabled: false, inviteLinkId: null, } diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 8fe400fb3..9dfc71a9c 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -4,7 +4,6 @@ import { createAudit } from "@/ee/features/audit/audit"; import { env, getSMTPConnectionURL } from "@sourcebot/shared"; import { ErrorCode } from "@/lib/errorCodes"; import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError"; -import { getOrgMetadata, isHttpError } from "@/lib/utils"; import { __unsafePrisma } from "@/prisma"; import { render } from "@react-email/components"; import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; @@ -13,16 +12,13 @@ import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; -import { isAnonymousAccessAvailable } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; import { createTransport } from "nodemailer"; -import { Octokit } from "octokit"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; -import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; +import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID } from "./lib/constants"; import { RepositoryQuery } from "./lib/types"; import { getAuthenticatedUser, withAuth, withOptionalAuth } from "./middleware/withAuth"; -import { withMinimumOrgRole } from "./middleware/withMinimumOrgRole"; import { getBrowsePath } from "./app/(app)/browse/hooks/utils"; import { sew } from "@/middleware/sew"; @@ -379,142 +375,6 @@ export const getRepoInfoByName = async (repoName: string) => sew(() => } })); -export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string): Promise<{ connectionId: number } | ServiceError> => sew(() => - withOptionalAuth(async ({ org, prisma }) => { - if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "This feature is not enabled.", - } satisfies ServiceError; - } - - // Parse repository URL to extract owner/repo - const repoInfo = (() => { - const url = repositoryUrl.trim(); - - // Handle various GitHub URL formats - const patterns = [ - // https://github.com/owner/repo or https://github.com/owner/repo.git - /^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/, - // github.com/owner/repo - /^github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/, - // owner/repo - /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/ - ]; - - for (const pattern of patterns) { - const match = url.match(pattern); - if (match) { - return { - owner: match[1], - repo: match[2] - }; - } - } - - return null; - })(); - - if (!repoInfo) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "Invalid repository URL format. Please use 'owner/repo' or 'https://github.com/owner/repo' format.", - } satisfies ServiceError; - } - - const { owner, repo } = repoInfo; - - // Use GitHub API to fetch repository information and get the external_id - const octokit = new Octokit({ - auth: env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN - }); - - let githubRepo; - try { - const response = await octokit.rest.repos.get({ - owner, - repo, - }); - githubRepo = response.data; - } catch (error) { - if (isHttpError(error, 404)) { - return { - statusCode: StatusCodes.NOT_FOUND, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `Repository '${owner}/${repo}' not found or is private. Only public repositories can be added.`, - } satisfies ServiceError; - } - - if (isHttpError(error, 403)) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `Access to repository '${owner}/${repo}' is forbidden. Only public repositories can be added.`, - } satisfies ServiceError; - } - - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `Failed to fetch repository information: ${error instanceof Error ? error.message : 'Unknown error'}`, - } satisfies ServiceError; - } - - if (githubRepo.private) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "Only public repositories can be added.", - } satisfies ServiceError; - } - - // Check if this repository is already connected using the external_id - const existingRepo = await prisma.repo.findFirst({ - where: { - orgId: org.id, - external_id: githubRepo.id.toString(), - external_codeHostType: 'github', - external_codeHostUrl: 'https://github.com', - } - }); - - if (existingRepo) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS, - message: "This repository already exists.", - } satisfies ServiceError; - } - - const connectionName = `${owner}-${repo}-${Date.now()}`; - - // Create GitHub connection config - const connectionConfig: GithubConnectionConfig = { - type: "github" as const, - repos: [`${owner}/${repo}`], - ...(env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN ? { - token: { - env: 'EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN' - } - } : {}) - }; - - const connection = await prisma.connection.create({ - data: { - orgId: org.id, - name: connectionName, - config: connectionConfig as unknown as Prisma.InputJsonValue, - connectionType: 'github', - } - }); - - return { - connectionId: connection.id, - } - })); - // eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth export const createAccountRequest = async () => sew(async () => { const authResult = await getAuthenticatedUser(); @@ -619,36 +479,6 @@ export const createAccountRequest = async () => sew(async () => { } }); -export const setMemberApprovalRequired = async (required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - await prisma.org.update({ - where: { id: org.id }, - data: { memberApprovalRequired: required }, - }); - - return { - success: true, - }; - }) - ) -); - -export const setInviteLinkEnabled = async (enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - await prisma.org.update({ - where: { id: org.id }, - data: { inviteLinkEnabled: enabled }, - }); - - return { - success: true, - }; - }) - ) -); - export const getSearchContexts = async () => sew(() => withOptionalAuth(async ({ org, prisma }) => { const searchContexts = await prisma.searchContext.findMany({ @@ -737,39 +567,6 @@ export const getRepoImage = async (repoId: number): Promise => sew(async () => { - return await withAuth(async ({ org, role, prisma }) => { - return await withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const anonymousAccessAvailable = await isAnonymousAccessAvailable(); - if (!anonymousAccessAvailable) { - console.error(`Anonymous access isn't supported in your current plan. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, - message: "Anonymous access is not supported in your current plan", - } satisfies ServiceError; - } - - const currentMetadata = getOrgMetadata(org); - const mergedMetadata = { - ...(currentMetadata ?? {}), - anonymousAccessEnabled: enabled, - }; - - await prisma.org.update({ - where: { - id: org.id, - }, - data: { - metadata: mergedMetadata, - }, - }); - - return true; - }); - }); -}); - // eslint-disable-next-line authz/require-auth-wrapper -- UI-only preference cookie, no DB access export const setAgenticSearchTutorialDismissedCookie = async (dismissed: boolean) => sew(async () => { const cookieStore = await cookies(); diff --git a/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx b/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx index 5d5002a65..93bf4b2c0 100644 --- a/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx @@ -1,3 +1,5 @@ +"use client" + import { Badge } from "@/components/ui/badge" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { OFFERINGS_DOCS_LINK } from "@/lib/constants" @@ -9,7 +11,7 @@ export const UpgradeBadge = () => { Pro diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index 8563e686b..15997cfd5 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -1,9 +1,3 @@ -/** - * All routes under (app) are dynamic since the layout calls auth() and - * accesses headers. - */ -export const dynamic = 'force-dynamic'; - import { __unsafePrisma } from "@/prisma"; import { auth } from "@/auth"; import { isServiceError } from "@/lib/utils"; @@ -18,7 +12,7 @@ import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { notFound, redirect } from "next/navigation"; import { PendingApprovalCard } from "./components/pendingApproval"; import { SubmitJoinRequest } from "./components/submitJoinRequest"; -import { env, getOfflineLicenseMetadata, SOURCEBOT_VERSION } from "@sourcebot/shared"; +import { env, getOfflineLicenseMetadata, SOURCEBOT_VERSION, isMemberApprovalRequired } from "@sourcebot/shared"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; import { GcpIapAuth } from "./components/gcpIapAuth"; import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; @@ -82,7 +76,7 @@ export default async function Layout(props: LayoutProps) { // the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats. // 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org. if (!membership) { - if (!org.memberApprovalRequired) { + if (!isMemberApprovalRequired(org)) { return (
diff --git a/packages/web/src/app/(app)/settings/access/page.tsx b/packages/web/src/app/(app)/settings/access/page.tsx deleted file mode 100644 index 580fb123c..000000000 --- a/packages/web/src/app/(app)/settings/access/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings"; -import { authenticatedPage } from "@/middleware/authenticatedPage"; -import { OrgRole } from "@sourcebot/db"; - -export default authenticatedPage(async () => { - return ( -
-
-

Access Control

-

Configure how users can access your Sourcebot deployment.{" "} - - Learn more - -

-
- - -
- ) -}, { - minRole: OrgRole.OWNER, - redirectTo: '/settings', -}); diff --git a/packages/web/src/app/(app)/settings/components/settingsCard.tsx b/packages/web/src/app/(app)/settings/components/settingsCard.tsx index ae4826d82..6649e002b 100644 --- a/packages/web/src/app/(app)/settings/components/settingsCard.tsx +++ b/packages/web/src/app/(app)/settings/components/settingsCard.tsx @@ -1,23 +1,25 @@ "use client"; import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; import { createContext, ReactNode, useContext, Children } from "react"; const SettingsCardGroupContext = createContext(false); interface SettingsCardProps { children: ReactNode; + className?: string; } -export function SettingsCard({ children }: SettingsCardProps) { +export function SettingsCard({ children, className }: SettingsCardProps) { const isInGroup = useContext(SettingsCardGroupContext); if (isInGroup) { - return
{children}
; + return
{children}
; } return ( -
+
{children}
); @@ -25,22 +27,27 @@ export function SettingsCard({ children }: SettingsCardProps) { interface BasicSettingsCardProps { name: string; - description: string; + description?: string; children: ReactNode; + footer?: ReactNode; + className?: string; } -export function BasicSettingsCard({ name, description, children }: BasicSettingsCardProps) { +export function BasicSettingsCard({ name, description, children, footer, className }: BasicSettingsCardProps) { return ( - +
-

{name}

-

{description}

+

{name}

+ {description && ( +

{description}

+ )}
{children}
+ {footer}
); } diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index f259f1603..cf640ac11 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -105,8 +105,8 @@ export const getSidebarNavGroups = async () => label: "Workspace", items: [ { - title: "Access", - href: `/settings/access`, + title: "Security", + href: `/settings/security`, icon: "shield" as const, }, { diff --git a/packages/web/src/app/(app)/settings/security/actions.ts b/packages/web/src/app/(app)/settings/security/actions.ts new file mode 100644 index 000000000..ef92d8d11 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/actions.ts @@ -0,0 +1,157 @@ +'use server'; + +import { getProviders } from "@/auth"; +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; +import { isAnonymousAccessAvailable } from "@/lib/entitlements"; +import { ErrorCode } from "@/lib/errorCodes"; +import { ServiceError } from "@/lib/serviceError"; +import { sew } from "@/middleware/sew"; +import { withAuth } from "@/middleware/withAuth"; +import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; +import { OrgRole } from "@sourcebot/db"; +import { env } from "@sourcebot/shared"; +import { StatusCodes } from "http-status-codes"; + +export const setMemberApprovalRequired = async (required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (env.REQUIRE_APPROVAL_NEW_MEMBERS !== undefined) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.MEMBER_APPROVAL_CONTROLLED_BY_ENV, + message: "Member approval is controlled by the REQUIRE_APPROVAL_NEW_MEMBERS environment variable and cannot be changed from the UI.", + } satisfies ServiceError; + } + + await prisma.org.update({ + where: { id: org.id }, + data: { memberApprovalRequired: required }, + }); + + return { + success: true, + }; + }) + ) +); + +export const setCredentialsLoginEnabled = async (enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (env.AUTH_CREDENTIALS_LOGIN_ENABLED !== undefined) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.EMAIL_LOGIN_CONTROLLED_BY_ENV, + message: "Email login is controlled by the AUTH_CREDENTIALS_LOGIN_ENABLED environment variable and cannot be changed from the UI.", + } satisfies ServiceError; + } + + const providers = await getProviders(); + const hasAlternativeLoginMethod = providers.some((provider) => provider.type !== "credentials"); + + // Don't allow disabling email login when it would leave no other way to + // sign in (i.e. no SSO identity providers and no magic-code email login). + if (!enabled && !hasAlternativeLoginMethod) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.EMAIL_LOGIN_CANNOT_BE_DISABLED, + message: "Email login cannot be disabled because no other login method is configured.", + } satisfies ServiceError; + } + + await prisma.org.update({ + where: { id: org.id }, + data: { isCredentialsLoginEnabled: enabled }, + }); + + return { + success: true, + }; + }) + ) +); + +export const setEmailCodeLoginEnabled = async (enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (env.AUTH_EMAIL_CODE_LOGIN_ENABLED !== undefined) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.EMAIL_CODE_LOGIN_CONTROLLED_BY_ENV, + message: "Email code login is controlled by the AUTH_EMAIL_CODE_LOGIN_ENABLED environment variable and cannot be changed from the UI.", + } satisfies ServiceError; + } + + const providers = await getProviders(); + const hasAlternativeLoginMethod = providers.some((provider) => provider.type !== "nodemailer"); + + // Don't allow disabling email code login when it would leave no other way to sign in. + if (!enabled && !hasAlternativeLoginMethod) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.EMAIL_CODE_LOGIN_CANNOT_BE_DISABLED, + message: "Email code login cannot be disabled because no other login method is configured.", + } satisfies ServiceError; + } + + await prisma.org.update({ + where: { id: org.id }, + data: { isEmailCodeLoginEnabled: enabled }, + }); + + return { + success: true, + }; + }) + ) +); + +export const setAnonymousAccessStatus = async (enabled: boolean): Promise => sew(async () => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (env.FORCE_ENABLE_ANONYMOUS_ACCESS !== undefined) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ANONYMOUS_ACCESS_CONTROLLED_BY_ENV, + message: "Anonymous access is controlled by the FORCE_ENABLE_ANONYMOUS_ACCESS environment variable and cannot be changed from the UI.", + } satisfies ServiceError; + } + + const anonymousAccessAvailable = await isAnonymousAccessAvailable(); + if (!anonymousAccessAvailable) { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: `Anonymous access is not supported in your current plan. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`, + } satisfies ServiceError; + } + + await prisma.org.update({ + where: { + id: org.id, + }, + data: { + isAnonymousAccessEnabled: enabled, + }, + }); + + return true; + }) + ) +); + + +export const setInviteLinkEnabled = async (enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + await prisma.org.update({ + where: { id: org.id }, + data: { inviteLinkEnabled: enabled }, + }); + + return { + success: true, + }; + }) + ) +); diff --git a/packages/web/src/app/(app)/settings/security/components/anonymousAccessEnabledSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/anonymousAccessEnabledSettingsCard.tsx new file mode 100644 index 000000000..961c8a845 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/anonymousAccessEnabledSettingsCard.tsx @@ -0,0 +1,58 @@ +"use client" + +import { useState } from "react" +import { Switch } from "@/components/ui/switch" +import { setAnonymousAccessStatus } from "@/app/(app)/settings/security/actions" +import { isServiceError } from "@/lib/utils" +import { useToast } from "@/components/hooks/use-toast" +import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" + +interface AnonymousAccessEnabledSettingsCardProps { + anonymousAccessEnabled: boolean +} + +export function AnonymousAccessEnabledSettingsCard({ anonymousAccessEnabled }: AnonymousAccessEnabledSettingsCardProps) { + const [enabled, setEnabled] = useState(anonymousAccessEnabled) + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setAnonymousAccessStatus(checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: result.message, + variant: "destructive", + }) + return + } + + setEnabled(checked) + } catch (error) { + console.error("Error updating anonymous access setting:", error) + toast({ + title: "Error", + description: "Failed to update anonymous access setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + return ( + + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/security/components/credentialsLoginEnabledSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/credentialsLoginEnabledSettingsCard.tsx new file mode 100644 index 000000000..0da704e5e --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/credentialsLoginEnabledSettingsCard.tsx @@ -0,0 +1,60 @@ +"use client" + +import { useState } from "react" +import { Switch } from "@/components/ui/switch" +import { setCredentialsLoginEnabled } from "@/app/(app)/settings/security/actions" +import { isServiceError } from "@/lib/utils" +import { useToast } from "@/components/hooks/use-toast" +import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" + +interface CredentialsLoginEnabledSettingsCardProps { + isCredentialsLoginEnabled: boolean +} + +export function CredentialsLoginEnabledSettingsCard({ + isCredentialsLoginEnabled, +}: CredentialsLoginEnabledSettingsCardProps) { + const [enabled, setEnabled] = useState(isCredentialsLoginEnabled) + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setCredentialsLoginEnabled(checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: result.message, + variant: "destructive", + }) + return + } + + setEnabled(checked) + } catch (error) { + console.error("Error updating email login setting:", error) + toast({ + title: "Error", + description: "Failed to update email login setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + return ( + + + + ) +} diff --git a/packages/web/src/app/(app)/settings/security/components/emailCodeLoginEnabledSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/emailCodeLoginEnabledSettingsCard.tsx new file mode 100644 index 000000000..1347ac979 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/emailCodeLoginEnabledSettingsCard.tsx @@ -0,0 +1,80 @@ +"use client" + +import { useState } from "react" +import { Info } from "lucide-react" +import { Switch } from "@/components/ui/switch" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { setEmailCodeLoginEnabled } from "@/app/(app)/settings/security/actions" +import { isServiceError } from "@/lib/utils" +import { useToast } from "@/components/hooks/use-toast" +import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" + +interface EmailCodeLoginEnabledSettingsCardProps { + isEmailCodeLoginEnabled: boolean + isEmailServiceConfigured: boolean +} + +export function EmailCodeLoginEnabledSettingsCard({ + isEmailCodeLoginEnabled, + isEmailServiceConfigured, +}: EmailCodeLoginEnabledSettingsCardProps) { + const [enabled, setEnabled] = useState(isEmailCodeLoginEnabled) + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setEmailCodeLoginEnabled(checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: result.message, + variant: "destructive", + }) + return + } + + setEnabled(checked) + } catch (error) { + console.error("Error updating email code login setting:", error) + toast({ + title: "Error", + description: "Failed to update email code login setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + return ( + + + + This setting requires transactional email to be configured.{" "} + + Learn more + + + + )} + > + + + ) +} diff --git a/packages/web/src/app/(app)/settings/security/components/identityProviderSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/identityProviderSettingsCard.tsx new file mode 100644 index 000000000..b21b871d4 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/identityProviderSettingsCard.tsx @@ -0,0 +1,39 @@ +import Image from "next/image"; +import { Badge } from "@/components/ui/badge"; +import { SettingsCard } from "@/app/(app)/settings/components/settingsCard"; +import { getAuthProviderInfo, cn } from "@/lib/utils"; +import { IdentityProvider } from "@/auth"; + +interface IdentityProviderSettingsCardProps { + provider: IdentityProvider; +} + +export function IdentityProviderSettingsCard({ provider }: IdentityProviderSettingsCardProps) { + const providerInfo = getAuthProviderInfo(provider.type); + const name = provider.displayName ?? providerInfo.displayName; + + return ( + +
+
+
+ {providerInfo.icon && ( + {name} + )} +
+
+

{name}

+ {provider.issuerUrl && ( +

{provider.issuerUrl}

+ )} +
+
+ Configured +
+
+ ); +} diff --git a/packages/web/src/app/(app)/settings/security/components/identityProviderUpsellCard.tsx b/packages/web/src/app/(app)/settings/security/components/identityProviderUpsellCard.tsx new file mode 100644 index 000000000..c0f033540 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/identityProviderUpsellCard.tsx @@ -0,0 +1,39 @@ +"use client" + +import { useState } from "react" +import { Sparkles } from "lucide-react" +import { Button } from "@/components/ui/button" +import { SettingsCard } from "@/app/(app)/settings/components/settingsCard" +import { UpsellDialog } from "@/features/billing/upsellDialog" + +export function IdentityProviderUpsellCard() { + const [isUpsellDialogOpen, setIsUpsellDialogOpen] = useState(false) + + return ( + <> + +
+
+
+ +
+
+

Single sign-on is a paid feature

+

Upgrade to let users authenticate with providers like GitHub, Google, and Okta.

+
+
+ +
+
+ + + + ) +} diff --git a/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx new file mode 100644 index 000000000..863fbe182 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { Copy, Check } from "lucide-react" +import { useToast } from "@/components/hooks/use-toast" +import { setInviteLinkEnabled } from "@/app/(app)/settings/security/actions" +import { cn, isServiceError } from "@/lib/utils" +import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" + +interface InviteLinkEnabledSettingsCardProps { + inviteLinkEnabled: boolean + inviteLink: string | null +} + +export function InviteLinkEnabledSettingsCard({ inviteLinkEnabled, inviteLink }: InviteLinkEnabledSettingsCardProps) { + const [enabled, setEnabled] = useState(inviteLinkEnabled) + const [isLoading, setIsLoading] = useState(false) + const [copied, setCopied] = useState(false) + const { toast } = useToast() + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setInviteLinkEnabled(checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: "Failed to update invite link setting", + variant: "destructive", + }) + return + } + + setEnabled(checked) + + } catch (error) { + console.error("Error updating invite link setting:", error) + toast({ + title: "Error", + description: "Failed to update invite link setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + const handleCopy = async () => { + if (!inviteLink) return + + try { + await navigator.clipboard.writeText(inviteLink) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error("Failed to copy text: ", err) + toast({ + title: "Error", + description: "Failed to copy invite link to clipboard", + variant: "destructive", + }) + } + } + + return ( + +
+
+
+ + +
+
+
+
+ } + > + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx new file mode 100644 index 000000000..3fca0e003 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx @@ -0,0 +1,60 @@ +"use client" + +import { useState } from "react" +import { Switch } from "@/components/ui/switch" +import { setMemberApprovalRequired } from "@/app/(app)/settings/security/actions" +import { isServiceError } from "@/lib/utils" +import { useToast } from "@/components/hooks/use-toast" +import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" + +interface MemberApprovalRequiredSettingsCardProps { + memberApprovalRequired: boolean +} + +export const MemberApprovalRequiredSettingsCard = ({ + memberApprovalRequired, +}: MemberApprovalRequiredSettingsCardProps) => { + const [enabled, setEnabled] = useState(memberApprovalRequired) + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setMemberApprovalRequired(checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: result.message, + variant: "destructive", + }) + return + } + + setEnabled(checked) + } catch (error) { + console.error("Error updating member approval setting:", error) + toast({ + title: "Error", + description: "Failed to update member approval setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + return ( + + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/security/page.tsx b/packages/web/src/app/(app)/settings/security/page.tsx new file mode 100644 index 000000000..e48e88c5f --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/page.tsx @@ -0,0 +1,123 @@ +import { AnonymousAccessEnabledSettingsCard } from "./components/anonymousAccessEnabledSettingsCard"; +import { InviteLinkEnabledSettingsCard } from "./components/inviteLinkEnabledSettingsCard"; +import { MemberApprovalRequiredSettingsCard } from "./components/memberApprovalRequiredSettingsCard"; +import { CredentialsLoginEnabledSettingsCard } from "./components/credentialsLoginEnabledSettingsCard"; +import { EmailCodeLoginEnabledSettingsCard } from "./components/emailCodeLoginEnabledSettingsCard"; +import { IdentityProviderSettingsCard } from "./components/identityProviderSettingsCard"; +import { IdentityProviderUpsellCard } from "./components/identityProviderUpsellCard"; +import { UpgradeBadge } from "@/app/(app)/@sidebar/components/upgradeBadge"; +import { getProviders, IdentityProvider } from "@/auth"; +import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; +import { createInviteLink } from "@/lib/utils"; +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { OrgRole } from "@sourcebot/db"; +import { env, getSMTPConnectionURL, isCredentialsLoginEnabled, isEmailCodeLoginEnabled, isMemberApprovalRequired } from "@sourcebot/shared"; +import { SettingsCardGroup } from "../components/settingsCard"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Info } from "lucide-react"; + +export default authenticatedPage(async ({ org }) => { + const anonymousAccessEnabled = await isAnonymousAccessEnabled(); + const inviteLink = createInviteLink(env.AUTH_URL, org.inviteLinkId); + const hasSSOEntitlement = await hasEntitlement("sso"); + const identityProviders = await getConfiguredIdentityProviders(); + + + return ( +
+

Security

+
+
+

Organization access

+

Configure how users can access your Sourcebot deployment.{" "} + + Learn more + +

+
+ + + + + + + +

Email login

+ + + + + + +
+
+

Single Sign-On

+ {!hasSSOEntitlement && } +
+

Let users sign in with an external identity provider such as GitHub, Google, or Okta. Providers are managed in your config file.{" "} + + Learn more + +

+
+ + {!hasSSOEntitlement ? ( + + ) : identityProviders.length > 0 ? ( + + {identityProviders.map((provider) => ( + + ))} + + ) : ( + + + + No identity providers are configured. Add them in your config file.{" "} + + Learn more + + + + )} +
+
+ ) +}, { + minRole: OrgRole.OWNER, + redirectTo: '/settings', +}); + +const getConfiguredIdentityProviders = async (): Promise => { + const providers = await getProviders(); + return providers.filter((provider) => + provider.purpose === "sso" && !["credentials", "nodemailer"].includes(provider.type) + ); +} \ No newline at end of file diff --git a/packages/web/src/app/components/anonymousAccessToggle.tsx b/packages/web/src/app/components/anonymousAccessToggle.tsx deleted file mode 100644 index 233faca5f..000000000 --- a/packages/web/src/app/components/anonymousAccessToggle.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client" - -import { useState } from "react" -import { Switch } from "@/components/ui/switch" -import { setAnonymousAccessStatus } from "@/actions" -import { isServiceError } from "@/lib/utils" -import { useToast } from "@/components/hooks/use-toast" - -interface AnonymousAccessToggleProps { - anonymousAccessAvailable: boolean; - anonymousAccessEnabled: boolean - forceEnableAnonymousAccess: boolean - onToggleChange?: (checked: boolean) => void -} - -export function AnonymousAccessToggle({ anonymousAccessAvailable, anonymousAccessEnabled, forceEnableAnonymousAccess, onToggleChange }: AnonymousAccessToggleProps) { - const [enabled, setEnabled] = useState(anonymousAccessEnabled) - const [isLoading, setIsLoading] = useState(false) - const { toast } = useToast() - - const handleToggle = async (checked: boolean) => { - setIsLoading(true) - try { - const result = await setAnonymousAccessStatus(checked) - - if (isServiceError(result)) { - toast({ - title: "Error", - description: result.message || "Failed to update anonymous access setting", - variant: "destructive", - }) - return - } - - setEnabled(checked) - onToggleChange?.(checked) - } catch (error) { - console.error("Error updating anonymous access setting:", error) - toast({ - title: "Error", - description: "Failed to update anonymous access setting", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - const isDisabled = isLoading || !anonymousAccessAvailable || forceEnableAnonymousAccess; - const showPlanMessage = !anonymousAccessAvailable; - const showForceEnableMessage = !showPlanMessage && forceEnableAnonymousAccess; - - return ( -
-
-
-

- Enable anonymous access -

-
-

- When enabled, users can access your deployment without logging in. -

- {showPlanMessage && ( -
-

- - - - - Your current plan doesn't allow for anonymous access. Please{" "} - - reach out - - {" "}for assistance. - -

-
- )} - {showForceEnableMessage && ( -
-

- - - - - FORCE_ENABLE_ANONYMOUS_ACCESS is set, so this cannot be changed from the UI. - -

-
- )} -
-
-
- -
-
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/components/inviteLinkToggle.tsx b/packages/web/src/app/components/inviteLinkToggle.tsx deleted file mode 100644 index bd610a2e9..000000000 --- a/packages/web/src/app/components/inviteLinkToggle.tsx +++ /dev/null @@ -1,129 +0,0 @@ -"use client" - -import { useState } from "react" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Switch } from "@/components/ui/switch" -import { Copy, Check } from "lucide-react" -import { useToast } from "@/components/hooks/use-toast" -import { setInviteLinkEnabled } from "@/actions" -import { isServiceError } from "@/lib/utils" - -interface InviteLinkToggleProps { - inviteLinkEnabled: boolean - inviteLink: string | null -} - -export function InviteLinkToggle({ inviteLinkEnabled, inviteLink }: InviteLinkToggleProps) { - const [enabled, setEnabled] = useState(inviteLinkEnabled) - const [isLoading, setIsLoading] = useState(false) - const [copied, setCopied] = useState(false) - const { toast } = useToast() - - - const handleToggle = async (checked: boolean) => { - setIsLoading(true) - try { - const result = await setInviteLinkEnabled(checked) - - if (isServiceError(result)) { - toast({ - title: "Error", - description: "Failed to update invite link setting", - variant: "destructive", - }) - return - } - - setEnabled(checked) - - } catch (error) { - console.error("Error updating invite link setting:", error) - toast({ - title: "Error", - description: "Failed to update invite link setting", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - - const handleCopy = async () => { - if (!inviteLink) return - - try { - await navigator.clipboard.writeText(inviteLink) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.error("Failed to copy text: ", err) - toast({ - title: "Error", - description: "Failed to copy invite link to clipboard", - variant: "destructive", - }) - } - } - - return ( -
-
-
-

- Enable invite link -

-
-

- When enabled, team members can use the invite link to join your organization without requiring approval. -

-
-
-
- -
-
- -
-
-
-
- - -
-
- -

- You can find this link again in the Settings → Members page. -

-
-
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/components/memberApprovalRequiredToggle.tsx b/packages/web/src/app/components/memberApprovalRequiredToggle.tsx deleted file mode 100644 index 2cb33db43..000000000 --- a/packages/web/src/app/components/memberApprovalRequiredToggle.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client" - -import { useState } from "react" -import { Switch } from "@/components/ui/switch" -import { setMemberApprovalRequired } from "@/actions" -import { isServiceError } from "@/lib/utils" -import { useToast } from "@/components/hooks/use-toast" - -interface MemberApprovalRequiredToggleProps { - memberApprovalRequired: boolean - onToggleChange?: (checked: boolean) => void - isControlledByEnvVar: boolean -} - -export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleChange, isControlledByEnvVar }: MemberApprovalRequiredToggleProps) { - const [enabled, setEnabled] = useState(memberApprovalRequired) - const [isLoading, setIsLoading] = useState(false) - const { toast } = useToast() - - const handleToggle = async (checked: boolean) => { - setIsLoading(true) - try { - const result = await setMemberApprovalRequired(checked) - - if (isServiceError(result)) { - toast({ - title: "Error", - description: "Failed to update member approval setting", - variant: "destructive", - }) - return - } - - setEnabled(checked) - onToggleChange?.(checked) - } catch (error) { - console.error("Error updating member approval setting:", error) - toast({ - title: "Error", - description: "Failed to update member approval setting", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - - const isDisabled = isLoading || isControlledByEnvVar; - - return ( -
-
-
-

- Require approval for new members -

-
-

- When enabled, new users will need approval from an organization owner before they can access your deployment. -

- {isControlledByEnvVar && ( -
-

- - - - - This setting is controlled by the REQUIRE_APPROVAL_NEW_MEMBERS environment variable. - -

-
- )} -
-
-
- -
-
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/components/organizationAccessSettings.tsx b/packages/web/src/app/components/organizationAccessSettings.tsx deleted file mode 100644 index 68915bb2e..000000000 --- a/packages/web/src/app/components/organizationAccessSettings.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { createInviteLink } from "@/lib/utils" -import { AnonymousAccessToggle } from "./anonymousAccessToggle" -import { OrganizationAccessSettingsWrapper } from "./organizationAccessSettingsWrapper" -import { SINGLE_TENANT_ORG_ID } from "@/lib/constants" -import { __unsafePrisma } from "@/prisma" -import { env } from "@sourcebot/shared" -import { isAnonymousAccessAvailable, isAnonymousAccessEnabled } from "@/lib/entitlements" - -interface OrganizationAccessSettingsProps { - showAnonymousAccessToggle?: boolean; -} - -export async function OrganizationAccessSettings({ showAnonymousAccessToggle = true }: OrganizationAccessSettingsProps = {}) { - const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } }); - if (!org) { - return
Error loading organization
- } - - const baseUrl = env.AUTH_URL; - const inviteLink = createInviteLink(baseUrl, org.inviteLinkId) - - const anonymousAccessEnabled = await isAnonymousAccessEnabled(); - const anonymousAccessAvailable = await isAnonymousAccessAvailable(); - - const forceEnableAnonymousAccess = env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; - const memberApprovalEnvVarSet = env.REQUIRE_APPROVAL_NEW_MEMBERS !== undefined; - - return ( -
- {showAnonymousAccessToggle && ( - - )} - - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/components/organizationAccessSettingsWrapper.tsx b/packages/web/src/app/components/organizationAccessSettingsWrapper.tsx deleted file mode 100644 index bc91963bf..000000000 --- a/packages/web/src/app/components/organizationAccessSettingsWrapper.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client" - -import { useState } from "react" -import { MemberApprovalRequiredToggle } from "./memberApprovalRequiredToggle" -import { InviteLinkToggle } from "./inviteLinkToggle" - -interface OrganizationAccessSettingsWrapperProps { - memberApprovalRequired: boolean - inviteLinkEnabled: boolean - inviteLink: string | null - memberApprovalEnvVarSet: boolean -} - -export function OrganizationAccessSettingsWrapper({ - memberApprovalRequired, - inviteLinkEnabled, - inviteLink, - memberApprovalEnvVarSet -}: OrganizationAccessSettingsWrapperProps) { - const [showInviteLink, setShowInviteLink] = useState(memberApprovalRequired) - - const handleMemberApprovalToggle = (checked: boolean) => { - setShowInviteLink(checked) - } - - return ( - <> -
- -
- -
- -
- - ) -} \ No newline at end of file diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts index a35bbcbdd..b05cde1ac 100644 --- a/packages/web/src/app/invite/actions.ts +++ b/packages/web/src/app/invite/actions.ts @@ -10,6 +10,7 @@ import { sew } from "@/middleware/sew"; import { getAuthenticatedUser } from "@/middleware/withAuth"; import { __unsafePrisma } from "@/prisma"; import { StatusCodes } from "http-status-codes"; +import { isMemberApprovalRequired } from "@sourcebot/shared"; // eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing export const joinOrganization = async (inviteLinkId?: string) => sew(async () => { @@ -32,7 +33,7 @@ export const joinOrganization = async (inviteLinkId?: string) => sew(async () => // If member approval is required we must be using a valid invite link - if (org.memberApprovalRequired) { + if (isMemberApprovalRequired(org)) { if (!org.inviteLinkEnabled) { return { statusCode: StatusCodes.BAD_REQUEST, diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index a16124428..3dac8f441 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -1,3 +1,11 @@ +/** + * Render all routes at request time rather than build time, + * since every route depends on per-request session, entitlements, + * and DB-backed data, none of which are available at build time. + * @see https://nextjs.org/docs/app/guides/caching-without-cache-components#route-segment-config + */ +export const dynamic = 'force-dynamic'; + import type { Metadata } from "next"; import Script from "next/script"; import "./globals.css"; diff --git a/packages/web/src/app/login/error/page.tsx b/packages/web/src/app/login/error/page.tsx new file mode 100644 index 000000000..2694feafb --- /dev/null +++ b/packages/web/src/app/login/error/page.tsx @@ -0,0 +1,108 @@ +"use client" + +import { Card, CardHeader, CardDescription, CardTitle, CardContent, CardFooter } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { ArrowLeft, AlertCircle } from "lucide-react" +import { useSearchParams } from "next/navigation" +import { Suspense } from "react" +import Link from "next/link" +import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import { Footer } from "@/app/components/footer" +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants" + +// @see https://authjs.dev/guides/pages/error +const ERROR_CONTENT: Record = { + Configuration: { + title: "Server configuration error", + description: "There is a problem with the server's authentication configuration. Please contact your administrator.", + }, + AccessDenied: { + title: "Access denied", + description: "You do not have permission to sign in.", + }, + Verification: { + title: "This sign-in link has expired", + description: "The code or link you used is no longer valid - it may have expired or already been used. Request a new one and try again.", + }, + Default: { + title: "Unable to sign in", + description: "Something went wrong while signing you in. Please try again.", + }, +} + +function ErrorPageContent() { + const searchParams = useSearchParams() + const error = searchParams.get("error") ?? "Default" + const { title, description } = ERROR_CONTENT[error] ?? ERROR_CONTENT.Default + + return ( +
+
+
+
+ +
+ + +
+
+ +
+
+ {title} + + {description} + +
+ + + + + + +
+

+ Having trouble?{" "} + + Contact support + +

+
+
+
+
+
+
+
+ ) +} + +function LoadingErrorPage() { + return ( +
+
+
+ +
+ + + Loading... + + +
+
+ ) +} + +export default function AuthErrorPage() { + return ( + }> + + + ) +} diff --git a/packages/web/src/app/login/verify/page.tsx b/packages/web/src/app/login/verify/page.tsx index a096f5f8b..8a28e112e 100644 --- a/packages/web/src/app/login/verify/page.tsx +++ b/packages/web/src/app/login/verify/page.tsx @@ -1,129 +1,12 @@ -"use client" - -import { InputOTPSeparator } from "@/components/ui/input-otp" -import { InputOTPGroup } from "@/components/ui/input-otp" -import { InputOTPSlot } from "@/components/ui/input-otp" -import { InputOTP } from "@/components/ui/input-otp" -import { Card, CardHeader, CardDescription, CardTitle, CardContent, CardFooter } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import { useRouter, useSearchParams } from "next/navigation" -import { useCallback, useState, Suspense } from "react" -import VerificationFailed from "./verificationFailed" -import { SourcebotLogo } from "@/app/components/sourcebotLogo" -import useCaptureEvent from "@/hooks/useCaptureEvent" -import { Footer } from "@/app/components/footer" -import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants" - -function VerifyPageContent() { - const [value, setValue] = useState("") - const searchParams = useSearchParams() - const email = searchParams.get("email") - const router = useRouter() - const captureEvent = useCaptureEvent(); - - const handleSubmit = useCallback(() => { - if (email && value.length === 6) { - const url = new URL("/api/auth/callback/nodemailer", window.location.origin) - url.searchParams.set("token", value) - url.searchParams.set("email", email) - router.push(url.toString()) - } - }, [value, email, router]) - - if (!email) { - captureEvent("wa_login_verify_page_no_email", {}) - return +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { VerifyForm } from "./verifyForm"; + +export default async function VerifyPage() { + const session = await auth(); + if (session) { + return redirect("/"); } - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && value.length === 6) { - handleSubmit() - } - } - - return ( -
-
-
-
- -
- - - Verify your email - - Enter the 6-digit code we sent to {email} - - - - -
{ - e.preventDefault() - handleSubmit() - }} className="space-y-6"> -
- - - - - - - - - - - - - -
-
-
- - - - -
-
-

- Having trouble?{" "} - - Contact support - -

-
-
-
-
-
- ) -} - -function LoadingVerifyPage() { - return ( -
-
-
- -
- - - Loading... - - -
-
- ) + return ; } - -export default function VerifyPage() { - return ( - }> - - - ) -} - diff --git a/packages/web/src/app/login/verify/verificationFailed.tsx b/packages/web/src/app/login/verify/verificationFailed.tsx deleted file mode 100644 index 98aeda111..000000000 --- a/packages/web/src/app/login/verify/verificationFailed.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import { AlertCircle } from "lucide-react" -import { SourcebotLogo } from "@/app/components/sourcebotLogo" -import { useRouter } from "next/navigation" -import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants" - -export default function VerificationFailed() { - const router = useRouter() - - return ( -
-
-
- -
- -
-
- -
-

Login verification failed

-

- Something went wrong when trying to verify your login. Please try again. -

-
- - -
- - -
- ) -} diff --git a/packages/web/src/app/login/verify/verifyForm.tsx b/packages/web/src/app/login/verify/verifyForm.tsx new file mode 100644 index 000000000..5f945a9c7 --- /dev/null +++ b/packages/web/src/app/login/verify/verifyForm.tsx @@ -0,0 +1,151 @@ +"use client" + +import { InputOTPSeparator } from "@/components/ui/input-otp" +import { InputOTPGroup } from "@/components/ui/input-otp" +import { InputOTPSlot } from "@/components/ui/input-otp" +import { InputOTP } from "@/components/ui/input-otp" +import { Card, CardHeader, CardDescription, CardTitle, CardContent, CardFooter } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { ArrowLeft, Loader2 } from "lucide-react" +import { useSearchParams } from "next/navigation" +import { useCallback, useState, Suspense } from "react" +import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import useCaptureEvent from "@/hooks/useCaptureEvent" +import { Footer } from "@/app/components/footer" +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants" +import { Redirect } from "@/app/components/redirect" + +function VerifyPageContent() { + const [value, setValue] = useState("") + const [isVerifying, setIsVerifying] = useState(false) + const searchParams = useSearchParams() + const email = searchParams.get("email") + const captureEvent = useCaptureEvent(); + + const handleSubmit = useCallback((code: string) => { + if (isVerifying || !email || code.length !== 6) { + return + } + + setIsVerifying(true) + const url = new URL("/api/auth/callback/nodemailer", window.location.origin) + url.searchParams.set("token", code) + url.searchParams.set("email", email) + // Use a full-page navigation (not router.push) so the auth callback's + // session cookie + 302 redirect are applied by the browser, and the + // one-time token isn't consumed twice by a client-side RSC navigation. + window.location.href = url.toString() + }, [email, isVerifying]) + + // Auto-submit once the full 6-digit code is entered. Pass the new value + // directly rather than reading `value`, which hasn't been committed yet. + const handleValueChange = (newValue: string) => { + setValue(newValue) + if (newValue.length === 6) { + handleSubmit(newValue) + } + } + + if (!email) { + captureEvent("wa_login_verify_page_no_email", {}) + return + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit(value) + } + } + + return ( +
+
+
+
+ +
+ + + Verify your email + + Enter the 6-digit code we sent to {email} + + + + +
{ + e.preventDefault() + handleSubmit(value) + }} className="space-y-6"> +
+ + + + + + + + + + + + + +
+ {isVerifying && ( +
+ + Verifying... +
+ )} +
+
+ + + + +
+
+

+ Having trouble?{" "} + + Contact support + +

+
+
+
+
+
+ ) +} + +function LoadingVerifyPage() { + return ( +
+
+
+ +
+ + + Loading... + + +
+
+ ) +} + +export function VerifyForm() { + return ( + }> + + + ) +} diff --git a/packages/web/src/app/onboard/components/accessSettingsStep.tsx b/packages/web/src/app/onboard/components/accessSettingsStep.tsx new file mode 100644 index 000000000..e08715fe0 --- /dev/null +++ b/packages/web/src/app/onboard/components/accessSettingsStep.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { __unsafePrisma } from "@/prisma"; +import { createInviteLink } from "@/lib/utils"; +import { env, isMemberApprovalRequired } from "@sourcebot/shared"; +import { MemberApprovalRequiredSettingsCard } from "@/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard"; +import { InviteLinkEnabledSettingsCard } from "@/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard"; +import { Org } from "@sourcebot/db"; + +interface AccessSettingsStepProps { + nextStep: number; + org: Org; +} + +export async function AccessSettingsStep({ nextStep, org }: AccessSettingsStepProps) { + const inviteLink = createInviteLink(env.AUTH_URL, org.inviteLinkId); + + return ( +
+ + + +
+ ); +} diff --git a/packages/web/src/app/onboard/components/ownerSignupStep.tsx b/packages/web/src/app/onboard/components/ownerSignupStep.tsx new file mode 100644 index 000000000..793bc1203 --- /dev/null +++ b/packages/web/src/app/onboard/components/ownerSignupStep.tsx @@ -0,0 +1,17 @@ +import { AuthMethodSelector } from "@/app/components/authMethodSelector"; + +interface OwnerSignupStepProps { + nextStep: number; +} + +export function OwnerSignupStep({ nextStep }: OwnerSignupStepProps) { + return ( +
+ +
+ ); +} diff --git a/packages/web/src/app/onboard/components/welcomeStep.tsx b/packages/web/src/app/onboard/components/welcomeStep.tsx new file mode 100644 index 000000000..c9050f627 --- /dev/null +++ b/packages/web/src/app/onboard/components/welcomeStep.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +interface WelcomeStepProps { + nextStep: number; +} + +export function WelcomeStep({ nextStep }: WelcomeStepProps) { + return ( +
+ +
+ ); +} diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index 8c156ac1e..ab6dd7ff6 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -1,12 +1,11 @@ import type React from "react" -import Link from "next/link" import { Card, CardContent } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { AuthMethodSelector } from "@/app/components/authMethodSelector" import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { auth } from "@/auth"; -import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings"; +import { WelcomeStep } from "./components/welcomeStep"; +import { OwnerSignupStep } from "./components/ownerSignupStep"; +import { AccessSettingsStep } from "./components/accessSettingsStep"; import { TrialStep, TrialStepTitle, TrialStepSubtitle } from "./components/trialStep"; import { AlreadyLicensedStep } from "./components/alreadyLicensedStep"; import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; @@ -79,13 +78,7 @@ export default async function Onboarding(props: OnboardingProps) { id: "welcome", title: "Welcome to Sourcebot", subtitle: "This onboarding flow will guide you through creating your owner account and configuring your organization.", - component: ( -
- -
- ), + component: , }); steps.push({ @@ -104,15 +97,7 @@ export default async function Onboarding(props: OnboardingProps) { . ), - component: ( -
- -
- ), + component: , }); steps.push({ @@ -132,13 +117,11 @@ export default async function Onboarding(props: OnboardingProps) { ), component: ( -
- - -
- ), + + ) }); const finalStepIndex = steps.length; @@ -178,38 +161,35 @@ export default async function Onboarding(props: OnboardingProps) { {steps.map((step, index) => (
-
- {/* Connecting line */} - {index < steps.length - 1 && ( -
- )} - {/* Circle - positioned above the line with z-index */} -
- {index < currentStep ? ( - - - - ) : ( - {index + 1} - )} -
-
+
+ {/* Connecting line */} + {index < steps.length - 1 && ( +
+ )} + {/* Circle - positioned above the line with z-index */} +
+ {index < currentStep ? ( + + + + ) : ( + {index + 1} + )} +
+
-
+
{step.title}
diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index a98c122ff..3240824e5 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -21,6 +21,7 @@ import { SINGLE_TENANT_ORG_ID } from './lib/constants'; import { EncryptedPrismaAdapter, encryptAccountData } from '@/lib/encryptedPrismaAdapter'; import { getAnonymousId } from '@/lib/anonymousId'; import { captureEvent } from '@/lib/posthog'; +import { isEmailCodeLoginEnabled, isCredentialsLoginEnabled } from '@sourcebot/shared' export const runtime = 'nodejs'; @@ -71,9 +72,10 @@ export const getProviders = async () => { const providers: IdentityProvider[] = [ ...(hasSSOEntitlement ? await getEEIdentityProviders() : []), ]; + const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } }); const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS && env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true') { + if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS && isEmailCodeLoginEnabled(org!)) { providers.push({ __provider: EmailProvider({ server: smtpConnectionUrl, @@ -106,7 +108,7 @@ export const getProviders = async () => { }); } - if (env.AUTH_CREDENTIALS_LOGIN_ENABLED === 'true') { + if (isCredentialsLoginEnabled(org!)) { providers.push({ __provider: Credentials({ credentials: { @@ -194,8 +196,8 @@ const nextAuthResult = NextAuth(async () => ({ // NOTE: Tokens are encrypted before storage for security if ( account && + (account.type === 'oauth' || account.type === 'oidc') && account.provider && - account.provider !== 'credentials' && account.providerAccountId ) { const issuerUrl = await getIssuerUrlForProviderId(account.provider); @@ -424,6 +426,7 @@ const nextAuthResult = NextAuth(async () => ({ providers: (await getProviders()).map((provider) => provider.__provider), pages: { signIn: "/login", + error: "/login/error", // We set redirect to false in signInOptions so we can pass the email in as a param // verifyRequest: "/login/verify", } diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index 33cf1491a..0a8eb90f9 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -32,6 +32,45 @@ const init = async () => { logger.info(`Member approval requirement set to ${requireApprovalNewMembers} via REQUIRE_APPROVAL_NEW_MEMBERS environment variable`); } } + + // Sync credentials (email + password) login setting from the (deprecated) env var (only if explicitly set) + if (env.AUTH_CREDENTIALS_LOGIN_ENABLED !== undefined) { + const isCredentialsLoginEnabled = env.AUTH_CREDENTIALS_LOGIN_ENABLED === 'true'; + const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } }); + if (org && org.isCredentialsLoginEnabled !== isCredentialsLoginEnabled) { + await __unsafePrisma.org.update({ + where: { id: org.id }, + data: { isCredentialsLoginEnabled }, + }); + logger.info(`Credentials login set to ${isCredentialsLoginEnabled} via AUTH_CREDENTIALS_LOGIN_ENABLED environment variable`); + } + } + + // Sync email code login setting from the (deprecated) env var (only if explicitly set) + if (env.AUTH_EMAIL_CODE_LOGIN_ENABLED !== undefined) { + const isEmailCodeLoginEnabled = env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true'; + const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } }); + if (org && org.isEmailCodeLoginEnabled !== isEmailCodeLoginEnabled) { + await __unsafePrisma.org.update({ + where: { id: org.id }, + data: { isEmailCodeLoginEnabled }, + }); + logger.info(`Email code login set to ${isEmailCodeLoginEnabled} via AUTH_EMAIL_CODE_LOGIN_ENABLED environment variable`); + } + } + + // Sync anonymous access setting from the (deprecated) env var (only if explicitly set) + if (env.FORCE_ENABLE_ANONYMOUS_ACCESS !== undefined) { + const isAnonymousAccessEnabled = env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; + const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } }); + if (org && org.isAnonymousAccessEnabled !== isAnonymousAccessEnabled) { + await __unsafePrisma.org.update({ + where: { id: org.id }, + data: { isAnonymousAccessEnabled }, + }); + logger.info(`Anonymous access set to ${isAnonymousAccessEnabled} via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`); + } + } } (async () => { diff --git a/packages/web/src/lib/entitlements.test.ts b/packages/web/src/lib/entitlements.test.ts index 0e72a7d7b..0ac391ab1 100644 --- a/packages/web/src/lib/entitlements.test.ts +++ b/packages/web/src/lib/entitlements.test.ts @@ -42,9 +42,9 @@ describe('isAnonymousAccessEnabled', () => { expect(await isAnonymousAccessEnabled()).toBe(false); }); - test('returns true when FORCE_ENABLE_ANONYMOUS_ACCESS is true, regardless of metadata', async () => { + test('returns true when FORCE_ENABLE_ANONYMOUS_ACCESS is true, regardless of the org setting', async () => { mocks.env.FORCE_ENABLE_ANONYMOUS_ACCESS = 'true'; - prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG, metadata: null }); + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG, isAnonymousAccessEnabled: false }); expect(await isAnonymousAccessEnabled()).toBe(true); }); @@ -62,44 +62,29 @@ describe('isAnonymousAccessEnabled', () => { expect(await isAnonymousAccessEnabled()).toBe(false); }); - test('returns false when org metadata is null', async () => { - prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG, metadata: null }); - - expect(await isAnonymousAccessEnabled()).toBe(false); - }); - - test('returns false when metadata.anonymousAccessEnabled is absent', async () => { - prisma.org.findUnique.mockResolvedValue({ - ...MOCK_ORG, - metadata: {}, - }); - - expect(await isAnonymousAccessEnabled()).toBe(false); - }); - - test('returns false when metadata.anonymousAccessEnabled is false', async () => { + test('returns false when org.isAnonymousAccessEnabled is false', async () => { prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG, - metadata: { anonymousAccessEnabled: false }, + isAnonymousAccessEnabled: false, }); expect(await isAnonymousAccessEnabled()).toBe(false); }); - test('returns true when metadata.anonymousAccessEnabled is true', async () => { + test('returns true when org.isAnonymousAccessEnabled is true', async () => { prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG, - metadata: { anonymousAccessEnabled: true }, + isAnonymousAccessEnabled: true, }); expect(await isAnonymousAccessEnabled()).toBe(true); }); - test('ignores FORCE_ENABLE_ANONYMOUS_ACCESS when not the string "true"', async () => { + test('returns false when FORCE_ENABLE_ANONYMOUS_ACCESS is "false", overriding the org setting', async () => { mocks.env.FORCE_ENABLE_ANONYMOUS_ACCESS = 'false'; prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG, - metadata: { anonymousAccessEnabled: false }, + isAnonymousAccessEnabled: true, }); expect(await isAnonymousAccessEnabled()).toBe(false); diff --git a/packages/web/src/lib/entitlements.ts b/packages/web/src/lib/entitlements.ts index 1dd136272..4ae7f8eb4 100644 --- a/packages/web/src/lib/entitlements.ts +++ b/packages/web/src/lib/entitlements.ts @@ -9,7 +9,6 @@ import { } from "@sourcebot/shared"; import { __unsafePrisma } from "@/prisma"; import { SINGLE_TENANT_ORG_ID } from "./constants"; -import { getOrgMetadata } from "./utils"; import { cache } from 'react'; const logger = createLogger('entitlements'); @@ -47,8 +46,8 @@ export const isAnonymousAccessEnabled = async () => { return false; } - if (env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true') { - return true; + if (env.FORCE_ENABLE_ANONYMOUS_ACCESS !== undefined) { + return env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; } const org = await __unsafePrisma.org.findUnique({ @@ -59,9 +58,7 @@ export const isAnonymousAccessEnabled = async () => { return false; } - const metadata = getOrgMetadata(org); - - return !!metadata?.anonymousAccessEnabled; + return org.isAnonymousAccessEnabled; } export const isValidLicenseActive = async () => { diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 94d10385e..2cea6d4ac 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -38,4 +38,10 @@ export enum ErrorCode { MCP_SERVER_ALREADY_EXISTS = 'MCP_SERVER_ALREADY_EXISTS', MCP_SERVER_NOT_FOUND = 'MCP_SERVER_NOT_FOUND', LIGHTHOUSE_UNREACHABLE = 'LIGHTHOUSE_UNREACHABLE', + EMAIL_LOGIN_CONTROLLED_BY_ENV = 'EMAIL_LOGIN_CONTROLLED_BY_ENV', + EMAIL_LOGIN_CANNOT_BE_DISABLED = 'EMAIL_LOGIN_CANNOT_BE_DISABLED', + EMAIL_CODE_LOGIN_CONTROLLED_BY_ENV = 'EMAIL_CODE_LOGIN_CONTROLLED_BY_ENV', + EMAIL_CODE_LOGIN_CANNOT_BE_DISABLED = 'EMAIL_CODE_LOGIN_CANNOT_BE_DISABLED', + MEMBER_APPROVAL_CONTROLLED_BY_ENV = 'MEMBER_APPROVAL_CONTROLLED_BY_ENV', + ANONYMOUS_ACCESS_CONTROLLED_BY_ENV = 'ANONYMOUS_ACCESS_CONTROLLED_BY_ENV', } diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index ef63c5b58..3ad283254 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -9,6 +9,7 @@ export type UpsellSource = 'onboard' | 'license_settings' | 'mcp_settings' | + 'sso_settings' | 'chat_connectors'; export type SourcebotWebClientSource = 'sourcebot-web-client'; diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 4a7533255..bc7720416 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -14,8 +14,7 @@ import microsoftLogo from "@/public/microsoft_entra.svg"; import authentikLogo from "@/public/authentik.svg"; import jumpcloudLogo from "@/public/jumpcloud.svg"; import { ServiceError } from "./serviceError"; -import { ConnectionType, Org } from "@sourcebot/db"; -import { OrgMetadata, orgMetadataSchema } from "@/types"; +import { ConnectionType } from "@sourcebot/db"; import { CodeHostType } from "@sourcebot/db"; export function cn(...inputs: ClassValue[]) { @@ -619,11 +618,6 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number): s } }; -export const getOrgMetadata = (org: Org): OrgMetadata | null => { - const currentMetadata = orgMetadataSchema.safeParse(org.metadata); - return currentMetadata.success ? currentMetadata.data : null; -} - export const isHttpError = (error: unknown, status: number): boolean => { return error !== null && typeof error === 'object' diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 4856483d3..bc0586615 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -1003,9 +1003,7 @@ describe('withOptionalAuth', () => { }); prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG, - metadata: { - anonymousAccessEnabled: true, - }, + isAnonymousAccessEnabled: true, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -1018,9 +1016,7 @@ describe('withOptionalAuth', () => { }, org: { ...MOCK_ORG, - metadata: { - anonymousAccessEnabled: true, - }, + isAnonymousAccessEnabled: true, }, prisma: undefined, }); @@ -1037,9 +1033,7 @@ describe('withOptionalAuth', () => { }); prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG, - metadata: { - anonymousAccessEnabled: true, - }, + isAnonymousAccessEnabled: true, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -1059,9 +1053,7 @@ describe('withOptionalAuth', () => { }); prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG, - metadata: { - anonymousAccessEnabled: false, - }, + isAnonymousAccessEnabled: false, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 6ac902f1b..0e930fa63 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -7,8 +7,8 @@ import { notAuthenticated, notFound, ServiceError } from "../lib/serviceError"; import { SINGLE_TENANT_ORG_ID } from "../lib/constants"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "../lib/errorCodes"; -import { getOrgMetadata, isServiceError } from "../lib/utils"; -import { hasEntitlement, isAnonymousAccessAvailable } from "@/lib/entitlements"; +import { isServiceError } from "../lib/utils"; +import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -51,17 +51,9 @@ export const withOptionalAuth = async (fn: (params: OptionalAuthContext) => P return authContext; } - const anonymousAccessAvailable = await isAnonymousAccessAvailable(); - const orgMetadata = getOrgMetadata(authContext.org); - if ( - ( - !authContext.user || - !authContext.role - ) && ( - !anonymousAccessAvailable || - !orgMetadata?.anonymousAccessEnabled - ) + (!authContext.user || !authContext.role) && + !(await isAnonymousAccessEnabled()) ) { return notAuthenticated(); } diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 2ceb5d30e..73ff02391 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -1,9 +1,5 @@ import { z } from "zod"; -export const orgMetadataSchema = z.object({ - anonymousAccessEnabled: z.boolean().optional(), -}) - export const demoSearchScopeSchema = z.object({ id: z.number(), displayName: z.string(), @@ -24,7 +20,6 @@ export const demoExamplesSchema = z.object({ searchExamples: demoSearchExampleSchema.array(), }) -export type OrgMetadata = z.infer; export type DemoExamples = z.infer; export type DemoSearchScope = z.infer; export type DemoSearchExample = z.infer; \ No newline at end of file